Skip to content

feat(citation_if): add citation_if resources server for citation instruction-following - #2586

Merged
cmunley1 merged 7 commits into
NVIDIA-NeMo:mainfrom
knayaka:citation-if-env
Aug 20, 2026
Merged

cmunley1 merged 7 commits into
NVIDIA-NeMo:mainfrom
knayaka:citation-if-env

Conversation

@knayaka

@knayaka knayaka commented Aug 18, 2026 •

Copy link
Copy Markdown
Contributor

What does this PR do?

Closes #2585

Adds resources_servers/citation_if, a resources server that grades citation
instruction-following
: did the model cite in the grammar it was told to use, cite only IDs
that exist in its retrieved context, and cite the ones that were required β€” and did it actually
answer. CPU-only string matching, one /verify endpoint, binary reward; no model calls, no GPU.

Each row is a complete frozen conversation β€” system instruction naming a citation grammar, a
question, search rounds, then a final user turn that closes retrieval and asks for synthesis.
The policy answers once.

Verification logic

Pure string matching in scorer.py (no FastAPI dependency, importable standalone). Gate
sequence in cite mode:

# gate fails when
0 structural empty final text, or the policy emitted a tool call
0b no_answer citation markup only β€” no word character survives once citations are removed
1 malformed_attempt after stripping well-formed spans, any residual ID-shaped token remains
2 must_cite fewer than min_valid_citations valid citations
3 no_hallucination a parsed ID is not in valid_id_set
4 correctness a required ID was missed, or `

mode=no_cite inverts gates 1-4 (reward 1 iff zero citations of any shape), which is how
abstention rows are expressed. 9 citation grammars; id_regex is authoritative per row, so
random-ID and numeric-ID schemes coexist in one pool. Two further grammars are marked
holdout: True and must never be assigned to training rows β€” they exist to detect grid
overfitting.

The answer's content is deliberately never graded. The reward is for
instruction-following and the data carries no gold answer. But an answer must exist β€” see
gate 0b. Those two properties are locked by an adjacent pair of tests so a future "fix" to
one cannot silently undo the other.

Source of prompts/tasks

The committed data/example.jsonl (5 rows) is synthetic β€” hand-written for this server,
covering four grammars, both id_kind values, and one no_cite row. It is a contract
example, not a difficulty example, and the README says so.

The training corpus this reward was developed against is internal and is not included.
Nothing in the server depends on it; configs/citation_if.yaml points train at a
gitignored path.

Tests β€” 327 passing

  • test_citation_if.py β€” hand-written gate tests, one or more per gate and failure mode
  • test_reward_hack_matrix.py β€” generated matrix, 16 known reward-hack classes x 9 grammars
    x 2 ID granularities = 265 cells; pass bar is 100%
  • test_residual_fuzz.py β€” 3 property tests x 1,000 mutations, both directions: leak
    mutations must score 0, benign ones (whitespace, punctuation, unicode quotes) must stay 1.
    An over-strict gate is as much a regression as a leaky one and much harder to notice.
  • test_app.py β€” server layer: Responses-API extraction contract and the /verify response

Licensing

Code: Apache 2.0. Dependencies: nemo_gym (Apache 2.0). No external corpus is
redistributed β€” the shipped example rows are synthetic.

Notes for review

  • verified: false, per the convention that the flag is maintainer-granted.
  • The top-level README.md change is generated by the update-readme-table pre-commit hook,
    not hand-edited.

Checklist

  • I have read the contributing guidelines.
  • The change is focused; unrelated "drive-by" edits are tracked as separate issues/PRs.
  • Tests added or updated and pass locally, or N/A for docs-only / non-code changes (so CI unit/server checks pass when applicable).
  • Pre-commit checks pass locally (pre-commit run --all-files) (so CI lint/format/copyright pass).
  • All commits have DCO sign-off (git commit -s) (so the DCO check passes).

…ruction-following

Signed-off-by: Kavya Nayaka <knayaka@nvidia.com>
@copy-pr-bot

copy-pr-bot Bot commented Aug 18, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@nemo-automation-bot nemo-automation-bot Bot added the community-request Issue reported or requested by someone from the community label Aug 18, 2026
@cwing-nvidia
cwing-nvidia requested a review from a team August 18, 2026 04:09
- nemo_gym: Apache 2.0

The example rows in `data/example.jsonl` are synthetic, written for this server. No external
corpus is redistributed here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if any data can be made public that would be great, or a data prep example, either via prepare.py

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The training corpus is internal and can't be published. I can add a prepare.py that generates synthetic rows in this format - covering the nine citation grammars, four instruction placements, both ID granularities and the no_cite inversion (bit heavy work) - so the server is usable end to end without our data. Would that satisfy the ask, or would you prefer something lighter, e.g. documenting the row schema and generation procedure in the README?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

either one works! thanks

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a "Building your own data" section in the README covering the row schema, trajectory shape, and generation procedure, with data/example.jsonl as a working instance.

@svcnvidia-nemo-ci svcnvidia-nemo-ci added the waiting-on-customer Waiting on the original author to respond label Aug 18, 2026

@jkyi-nvidia jkyi-nvidia left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks great let's just make sure the data includes guards for max tool calls allowed somewhere and write that point down explicitly in README so that it is not forgotten and noted.

Comment thread resources_servers/citation_if/app.py Resolved
@svcnvidia-nemo-ci svcnvidia-nemo-ci added waiting-on-customer Waiting on the original author to respond and removed waiting-on-customer Waiting on the original author to respond labels Aug 19, 2026
…ta, not the server

Signed-off-by: Kavya Nayaka <knayaka@nvidia.com>
…nv checks are added

Signed-off-by: Kavya Nayaka <knayaka@nvidia.com>
…tead of the unpublished corpus

Signed-off-by: Kavya Nayaka <knayaka@nvidia.com>
cmunley1
cmunley1 previously approved these changes Aug 20, 2026
@cmunley1

Copy link
Copy Markdown
Contributor

/claude review

@claude

claude Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

SHIP β€” LGTM, no reliability concerns.

Reviewed the full diff, scorer.py, all four test files, config, README, and example data; verified the public-API contract and route ordering against SimpleResourcesServer.

Why this is low-risk:

  • Verifier is pure and CPU-only. score_citation_if is deterministic string matching β€” no model calls, no async, no ray.get, no httpx. verify() delegates to it; the POST /{tool_name} catch-all just returns a fixed PlainTextResponse.
  • Route ordering is safe. The catch-all is registered after super().setup_webserver(), so exact routes (/verify, /seed_session, /aggregate_metrics, /reverify_mode) match first and are not shadowed β€” same pattern as ns_tools/finance_agent_v2.
  • Correctness is well-locked. Gate ordering is deliberate and documented; per-gate tests, a generated 265-cell reward-hack matrix with a populated-ness guard, and bidirectional residual fuzz (leak and over-strict) all assert real behavior. Tests are not vacuous.
  • Reasoning-leak guard holds. extract_response_shape reads only assistant message output, so a citation emitted inside reasoning earns nothing β€” the key silent-corruption path, and it is tested directly.
  • Conventions followed. requirements.txt uses the standard editable install, config carries verified: false, README table updated, and the three required example artifacts ship while data/* stays gitignored.

Nothing material to flag.

Signed-off-by: Kavya Nayaka <knayaka@nvidia.com>
@cmunley1
cmunley1 enabled auto-merge (squash) August 20, 2026 22:42
@cmunley1
cmunley1 disabled auto-merge August 20, 2026 22:44
@cmunley1
cmunley1 merged commit 72091ad into NVIDIA-NeMo:main Aug 20, 2026
8 checks passed
@svcnvidia-nemo-ci svcnvidia-nemo-ci removed the waiting-on-customer Waiting on the original author to respond label Aug 21, 2026
pjin-nvidia pushed a commit that referenced this pull request Sep 4, 2026
…ruction-following (#2586)

## What does this PR do?

Closes #2585

Adds `resources_servers/citation_if`, a resources server that grades
**citation
instruction-following**: did the model cite in the grammar it was told
to use, cite only IDs
that exist in its retrieved context, and cite the ones that were
required β€” and did it actually
answer. CPU-only string matching, one `/verify` endpoint, binary reward;
no model calls, no GPU.

Each row is a complete frozen conversation β€” system instruction naming a
citation grammar, a
question, search rounds, then a final user turn that closes retrieval
and asks for synthesis.
The policy answers once.

### Verification logic
Pure string matching in `scorer.py` (no FastAPI dependency, importable
standalone). Gate
sequence in cite mode:

| # | gate | fails when |
|---|---|---|
| 0 | `structural` | empty final text, or the policy emitted a tool call
|
| 0b | `no_answer` | citation markup only β€” no word character survives
once citations are removed |
| 1 | `malformed_attempt` | after stripping well-formed spans, any
residual ID-shaped token remains |
| 2 | `must_cite` | fewer than `min_valid_citations` valid citations |
| 3 | `no_hallucination` | a parsed ID is not in `valid_id_set` |
| 4 | `correctness` | a required ID was missed, or `|cited| > |expected|
+ expected_slack` |

`mode=no_cite` inverts gates 1-4 (reward 1 iff zero citations of any
shape), which is how
abstention rows are expressed. 9 citation grammars; `id_regex` is
authoritative per row, so
random-ID and numeric-ID schemes coexist in one pool. Two further
grammars are marked
`holdout: True` and must never be assigned to training rows β€” they exist
to detect grid
overfitting.

**The answer's content is deliberately never graded.** The reward is for
instruction-following and the data carries no gold answer. But an answer
must *exist* β€” see
gate 0b. Those two properties are locked by an adjacent pair of tests so
a future "fix" to
one cannot silently undo the other.

### Source of prompts/tasks
The committed `data/example.jsonl` (5 rows) is **synthetic β€”
hand-written for this server**,
covering four grammars, both `id_kind` values, and one `no_cite` row. It
is a contract
example, not a difficulty example, and the README says so.

The training corpus this reward was developed against is internal and
**is not included**.
Nothing in the server depends on it; `configs/citation_if.yaml` points
`train` at a
gitignored path.

### Tests β€” 327 passing
- `test_citation_if.py` β€” hand-written gate tests, one or more per gate
and failure mode
- `test_reward_hack_matrix.py` β€” generated matrix, 16 known reward-hack
classes x 9 grammars
  x 2 ID granularities = 265 cells; pass bar is 100%
- `test_residual_fuzz.py` β€” 3 property tests x 1,000 mutations, **both
directions**: leak
mutations must score 0, benign ones (whitespace, punctuation, unicode
quotes) must stay 1.
An over-strict gate is as much a regression as a leaky one and much
harder to notice.
- `test_app.py` β€” server layer: Responses-API extraction contract and
the `/verify` response

### Licensing
Code: Apache 2.0. Dependencies: `nemo_gym` (Apache 2.0). No external
corpus is
redistributed β€” the shipped example rows are synthetic.

### Notes for review
- `verified: false`, per the convention that the flag is
maintainer-granted.
- The top-level `README.md` change is generated by the
`update-readme-table` pre-commit hook,
  not hand-edited.

## Checklist

- [x] I have read the [contributing
guidelines](https://docs.nvidia.com/nemo/gym/latest/contribute/development-setup).
- [x] The change is focused; unrelated "drive-by" edits are tracked as
separate issues/PRs.
- [x] Tests added or updated and pass locally, or N/A for docs-only /
non-code changes (so CI unit/server checks pass when applicable).
- [x] Pre-commit checks pass locally (`pre-commit run --all-files`) (so
CI lint/format/copyright pass).
- [x] All commits have DCO sign-off (`git commit -s`) (so the DCO check
passes).

---------

Signed-off-by: Kavya Nayaka <knayaka@nvidia.com>
Co-authored-by: Christian Munley <cmunley@nvidia.com>
Signed-off-by: Peter Jin <pjin@nvidia.com>
yfw pushed a commit that referenced this pull request Sep 4, 2026
…ruction-following (#2586)

## What does this PR do?

Closes #2585

Adds `resources_servers/citation_if`, a resources server that grades
**citation
instruction-following**: did the model cite in the grammar it was told
to use, cite only IDs
that exist in its retrieved context, and cite the ones that were
required β€” and did it actually
answer. CPU-only string matching, one `/verify` endpoint, binary reward;
no model calls, no GPU.

Each row is a complete frozen conversation β€” system instruction naming a
citation grammar, a
question, search rounds, then a final user turn that closes retrieval
and asks for synthesis.
The policy answers once.

### Verification logic
Pure string matching in `scorer.py` (no FastAPI dependency, importable
standalone). Gate
sequence in cite mode:

| # | gate | fails when |
|---|---|---|
| 0 | `structural` | empty final text, or the policy emitted a tool call
|
| 0b | `no_answer` | citation markup only β€” no word character survives
once citations are removed |
| 1 | `malformed_attempt` | after stripping well-formed spans, any
residual ID-shaped token remains |
| 2 | `must_cite` | fewer than `min_valid_citations` valid citations |
| 3 | `no_hallucination` | a parsed ID is not in `valid_id_set` |
| 4 | `correctness` | a required ID was missed, or `|cited| > |expected|
+ expected_slack` |

`mode=no_cite` inverts gates 1-4 (reward 1 iff zero citations of any
shape), which is how
abstention rows are expressed. 9 citation grammars; `id_regex` is
authoritative per row, so
random-ID and numeric-ID schemes coexist in one pool. Two further
grammars are marked
`holdout: True` and must never be assigned to training rows β€” they exist
to detect grid
overfitting.

**The answer's content is deliberately never graded.** The reward is for
instruction-following and the data carries no gold answer. But an answer
must *exist* β€” see
gate 0b. Those two properties are locked by an adjacent pair of tests so
a future "fix" to
one cannot silently undo the other.

### Source of prompts/tasks
The committed `data/example.jsonl` (5 rows) is **synthetic β€”
hand-written for this server**,
covering four grammars, both `id_kind` values, and one `no_cite` row. It
is a contract
example, not a difficulty example, and the README says so.

The training corpus this reward was developed against is internal and
**is not included**.
Nothing in the server depends on it; `configs/citation_if.yaml` points
`train` at a
gitignored path.

### Tests β€” 327 passing
- `test_citation_if.py` β€” hand-written gate tests, one or more per gate
and failure mode
- `test_reward_hack_matrix.py` β€” generated matrix, 16 known reward-hack
classes x 9 grammars
  x 2 ID granularities = 265 cells; pass bar is 100%
- `test_residual_fuzz.py` β€” 3 property tests x 1,000 mutations, **both
directions**: leak
mutations must score 0, benign ones (whitespace, punctuation, unicode
quotes) must stay 1.
An over-strict gate is as much a regression as a leaky one and much
harder to notice.
- `test_app.py` β€” server layer: Responses-API extraction contract and
the `/verify` response

### Licensing
Code: Apache 2.0. Dependencies: `nemo_gym` (Apache 2.0). No external
corpus is
redistributed β€” the shipped example rows are synthetic.

### Notes for review
- `verified: false`, per the convention that the flag is
maintainer-granted.
- The top-level `README.md` change is generated by the
`update-readme-table` pre-commit hook,
  not hand-edited.

## Checklist

- [x] I have read the [contributing
guidelines](https://docs.nvidia.com/nemo/gym/latest/contribute/development-setup).
- [x] The change is focused; unrelated "drive-by" edits are tracked as
separate issues/PRs.
- [x] Tests added or updated and pass locally, or N/A for docs-only /
non-code changes (so CI unit/server checks pass when applicable).
- [x] Pre-commit checks pass locally (`pre-commit run --all-files`) (so
CI lint/format/copyright pass).
- [x] All commits have DCO sign-off (`git commit -s`) (so the DCO check
passes).

---------

Signed-off-by: Kavya Nayaka <knayaka@nvidia.com>
Co-authored-by: Christian Munley <cmunley@nvidia.com>
Signed-off-by: Peter Jin <pjin@nvidia.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

community-request Issue reported or requested by someone from the community

Projects

None yet

Development

Successfully merging this pull request may close these issues.

New resources server: citation_if - citation instruction-following reward checker

5 participants