Repository navigation
Conversation
|
@mosheabr : What do you think about this Benchmark changes? |
rng1995
left a comment
There was a problem hiding this comment.
The source-identity plumbing is useful and the focused suite (166 tests), Ruff, and remote CI pass, but four provenance guarantees are still missing: normal validation does not supply or enforce the identity, contradictory carriers silently pass, revision syntax accepts ambiguous or algorithm-invalid values, and strict mode omits the evaluator container revision. These make a published PASS appear source-bound when it is not. Please address the inline findings; the current CHANGELOG.md conflict with main also needs resolution.
A published BENCHMARK.md recorded the evaluator build but not the source tree it evaluated. Two skills evaluated from different repositories by the same evaluator container therefore produced cards whose only revision was the shared container tag, so a reviewer could not tell which version of a skill was evaluated or whether a card had gone stale. Carry an evaluated-source identity through the result contract and render it with labels unambiguously distinct from the evaluator provenance: - Evaluated source: `NVIDIA/NVFlare` - Evaluated source revision: `2263a2eb...` - Evaluator container revision: `ghcr.io/nvidia/skillevaluator@sha256:...` The identity comes from the orchestration input and is never inferred from repository state while rendering: the tree that renders a card is the evaluator checkout, not the evaluated source. It can arrive as the `evaluated_source` argument, as `evaluated_source` in `run_config.json`, or as `metadata["evaluated_source"]` on any validation result -- the last so a card that publishes without a completed Tier 3 run still has a carrier. Where two channels supply the same field and disagree, resolution fails rather than silently picking one, because a card that quietly chose between two contradictory revisions is the unverifiable provenance this contract exists to prevent. `skillevaluator.source_identity` holds one definition of the identity so producer, renderer and gate agree. Values are accepted only in canonical shape and are then published verbatim rather than Markdown-escaped, because escaping rewrites `_` to `\_` and `@` to `@` and would publish a repository name and a digest-pinned image reference that no longer resolve. Every accepted character is inert inside a code span, and the renderer re-validates rather than trusting the payload, so a card built from a hand-made or legacy metadata dict drops a hostile value instead of escaping it into the output. The revision line is labelled `revision` rather than `commit` because the issue accepts either a commit SHA or a canonical content digest, and an absent identity says it was not supplied by the orchestration input rather than blaming a legacy or non-live result. `check_public_benchmarks.py --require-source-provenance` requires the three fields and fails any card publishing a PASS without a recorded repository and revision, whether or not that verdict is blockquoted and whatever the Tier 3 row says. The whole check is opt-in: cards generated before this contract existed cannot satisfy it, so the default scan stays byte compatible with its previous behaviour and CI can enable the flag once the orchestration side supplies the identity. Closes #72 Signed-off-by: Max Freedom Pollard <[email protected]>
…ntities Addresses three review findings on the evaluated-source contract. Contradictory carriers no longer resolve by position. The renderer read the Tier 3 payload, that payload's summary and every result's metadata as an ordered candidate list and published the first that validated, so a card whose Tier 3 payload named one repository and whose Tier 1 result named another published whichever the iteration order reached first. Result ordering follows tier execution rather than orchestration input, so it must not decide what a card claims to describe. All populated carriers are now folded together and a field two carriers disagree about raises EvaluatedSourceConflict. The existing two-channel resolver is the two-item case of that fold, so its per-field merge and its error wording are unchanged. Because the card is generated compulsorily for skills, `validate` reports that conflict as a CLI error naming the values that disagreed, rather than an unhandled traceback. Revisions are accepted only in canonical shapes. The commit pattern admitted a 7-character prefix, which can grow a second match as a tree grows, and the digest pattern admitted any algorithm followed by 32 to 128 hex characters, so sha512 with 32 hex characters rendered as immutable provenance. A commit must now be a full Git object id (40 or 64 hex) and a digest must match the width of the algorithm it names. The stdlib-only publication gate mirrors both rules. Strict publication mode now requires the evaluator container revision, and requires it to be pinned by digest. Blanking the golden card's container digest previously produced no offenders, which contradicted both the flag's documentation and the confusion issue #72 reported, where the only recorded revision was a shared evaluator image. Tests cover each rule, including the reviewer's reproduction: a Tier 3 payload and a Tier 1 result naming different repositories now fails closed in either result order, rather than publishing one of them. Signed-off-by: Max Freedom Pollard <[email protected]>
…n input Nothing in the tree populated the identity, so a normal validate run rendered all three provenance fields as not recorded and the confusion issue #72 reported stayed reproducible. `validate` now accepts --evaluated-source-repository, --evaluated-source-revision and --evaluator-container-revision. One revision option covers both immutable shapes the card accepts, matching its single "Evaluated source revision" line: a full Git object id, or a content digest for a run with no upstream commit. A value that is not canonical is refused at the CLI boundary rather than dropped, so a card never reports not recorded for a field the operator believed they had supplied. The identity reaches the card through the result metadata carrier, which is the seam that works for every card shape including a Tier 1-only PASS. It is attached with setdefault, so an identity a producer already recorded stays in place and a disagreement between the two is reported rather than resolved. For a Tier 3 run it also travels EvaluationOptions to tier3.commands.evaluate to the Harbor runner, which persists it in run_config.json, so a card rendered later from that run directory records the same source tree. CI now runs the publication scan with --require-source-provenance. The flag remains opt-in for trees whose cards predate the contract. Without these options the card renders not recorded exactly as before, which the default-path test asserts. Signed-off-by: Max Freedom Pollard <[email protected]>
6bb9b2c to
c568326
Compare
The evaluator container revision was capped at 128 characters over the whole reference, which charges the `@sha256:` suffix to the repository name. A 68-character repository pinned by digest is 140 characters, so it was silently dropped and the card reported the revision as not recorded even though the orchestration input had supplied it. The field is validated against the OCI reference grammar instead, component by component: a name of at most 255 characters (NameTotalLengthMax) built from an optional registry domain with an optional port and slash-separated path components, an optional tag of at most 128 characters, and an optional digest at its own algorithm's width, from the same allowlist the content digest uses. A reference carrying neither a tag nor a digest is refused, because a bare name identifies a repository rather than the build that ran. An evaluator run from a source checkout still records its own implementation revision, and a published PASS still requires the digest, because a tag can be repointed afterwards. Every accepted character remains inert inside a Markdown code span, so the card can keep rendering the value verbatim rather than escaping an identity into something that no longer names the thing it identifies. The stdlib-only publication gate mirrors the library rather than importing it, so it carries the same pattern text and the same length rule as literals, and a new test compares the two strings so the copies cannot drift unnoticed. Both sides now hold a predicate per field, which is what lets one rule combine a pattern with a length check the pattern cannot express. Signed-off-by: Max Freedom Pollard <[email protected]>
One ordering-dependent path survived the previous fix. The card resolved the evaluated source from the first result carrying an `agent_eval` payload, so a run that produced two agent-eval results published whichever payload came first: reversing the result list flipped the card to the other source tree instead of raising EvaluatedSourceConflict. Result order follows how a run was scheduled, so it must not decide what a published card claims to describe. `source_identity` now owns the whole set of carriers. `evaluated_source_carriers` names the three places one result can record the identity: its own metadata, the agent-eval payload, and that payload's summary. `recorded_evaluated_source` folds every carrier of every result through the existing per-field merge, so a second payload can no longer be shadowed and a disagreement anywhere fails closed in either order. Keeping the set in one place also keeps the card and the machine-readable report reading the same carriers, rather than honouring one in a report and ignoring it in another. The renderer still selects the first payload for the score and run-detail lines, which describe a single run, and no longer uses that selection to resolve provenance. The regression covers the reviewer's reproduction in both orders: two full payloads naming different repositories, and a first payload against a summary-only carrier on a second result. Two payloads that agree still render the identity rather than reading as a conflict. Signed-off-by: Max Freedom Pollard <[email protected]>
`validate` attached and resolved the identity after `emit_reports`, so the machine-readable report disagreed with the card published beside it. A Tier 1-only run wrote a JSON report carrying neither the source repository nor its revision while BENCHMARK.md recorded both, and a run whose carriers contradicted each other wrote JSON and HTML naming one of the two before benchmark generation failed the run. The attach and resolve step now runs immediately after `apply_policy`, for every content type rather than only for skills, so a run that cannot say what it evaluated writes nothing at all. The supplied identity is folded in as its own carrier rather than only attached: `setdefault` is silent where it declines to overwrite, so a result that already recorded a different source tree would otherwise publish that one and drop the operator's orchestration input unreported. The BENCHMARK.md block keeps its own conflict handling as a last line of defence, because the renderer re-validates whatever carriers it is handed. The JSON report now always carries a top-level `evaluated_source` resolved from the same fold, so CI can verify provenance from the file it already parses. The key is always present: `null` says the orchestration input supplied none, rather than leaving a consumer to guess whether an older reporter omitted it. Tests cover a Tier 1-only run whose JSON report and card record the same identity, the JSON contract for a recorded, absent and contradictory identity, and a payload/CLI conflict that leaves no JSON, HTML or BENCHMARK.md behind. Signed-off-by: Max Freedom Pollard <[email protected]>
…llel catalog children The single-skill `validate` path supplied the identity, but the two other producers still dropped it, so the contract held only for the narrowest run shape. `tier3 evaluate` exposed no provenance options at all. It owns its own run directory, so a standalone Tier 3 run persisted `evaluated_source: null` into `run_config.json` and any card rendered later from that directory reported the source tree as not recorded, which is the confusion the contract exists to prevent. The command now accepts the same three options as `validate`, with the same names and the same canonical-shape refusal at the CLI boundary, and passes the resolved identity through `EvaluationOptions` to the engine. The visible `tier3 evaluate` re-lists the hidden command's params, so both spellings pick the options up. `validate <catalog> --workers > 1` rebuilds each child's argv from an explicit allowlist, and that allowlist omitted all three options. Every child renders its own BENCHMARK.md, so a parallel catalog run published a whole directory of cards saying the evaluated source was never recorded while the serial run of the same catalog recorded it. The three options are now forwarded with the rest. A drift guard covers the allowlist itself: every `validate` parameter must either be forwarded by name or appear in the test's not-forwarded map with the reason it must stay behind, so the next option added to `validate` cannot be silently dropped on the way to a child. The standalone path is covered down to disk rather than by signature: a run driven through `tier3.commands.evaluate` with a failing runtime preflight still writes `run_config.json`, and the test asserts the identity is in it. The parallel path is covered end to end on a two-skill catalog, asserting both children's cards name the repository and the supplied revision. Signed-off-by: Max Freedom Pollard <[email protected]>
The entry now covers the producers that carry the identity (both commands, parallel catalog children and the JSON report), that conflicting carriers fail before any report is written, and the component rules a container revision is validated against. Signed-off-by: Max Freedom Pollard <[email protected]>
`_finalize_evaluated_source` attached the supplied identity with `setdefault`
and then folded the carriers only to detect a conflict, discarding the merged
value. A result that had already recorded part of the identity therefore
shadowed the rest of it. A Tier 1 result carrying just
`metadata["evaluated_source"] = {"repository": "NVIDIA/NVFlare"}`, which is the
documented seam for a card published without a completed Tier 3 run, published
a card and a JSON report calling the revision and the evaluator container not
recorded, though the operator had supplied both on the command line. Nothing
said so: the fold saw no disagreement, because a partial carrier does not
conflict with the fields it omits.
The fold's result is now what gets written. Every carrier, the orchestration
input included, is merged into one identity, and that union is written back to
each result whose metadata is a mapping, as a fresh dict per result so no two
results and no caller share one mutable identity. A run that recorded no
identity anywhere still has none written, so absent stays absent rather than
becoming empty. Conflicts keep failing closed with nothing on disk.
The hidden `evaluate` command advertised its repository option as "recorded on
BENCHMARK.md", which that command never writes. It says where the value
actually lands instead: the run's `run_config.json`, which a card rendered
later reads.
Signed-off-by: Max Freedom Pollard <[email protected]>
The reference grammar read any single first label as a registry host, so `NVIDIA/skillevaluator@sha256:...` was accepted while the same repository under a registry, `ghcr.io/NVIDIA/skillevaluator@sha256:...`, was refused. The OCI grammar has no such asymmetry. A first component is a host only when it is `localhost`, when it carries a dot, or when it carries a port; anything else begins the path, whose components are lower case. Both spellings above are an uppercase path component, so both are refused now, and a host is recognised in the three shapes the grammar names, in any case, because DNS is case-insensitive. The 255-character bound was applied to the host and the path together. The grammar's RepositoryNameTotalLengthMax measures the path once the host has been split off it, so a long registry name was spending a budget that belongs to the path. The path is now its own capture group and the bound measures that group, in the library and in the gate alike. The constant says path rather than name, so the two cannot be read as the same thing again. The gate's `_GIT_OBJECT_ID` was a hand copy of `_SOURCE_COMMIT` that the drift test never compared, so it could have drifted while the reference grammar still matched. Its text is now identical, anchors included, and the drift test compares it alongside the reference pattern and the bound. Adversarial inputs were measured through both copies of the grammar before this landed: 5,000-character runs of `-`, `.`, `_`, `/` and alternating `a-` complete in about 1.2 ms, so the added alternation costs no catastrophic backtracking. The docs and the changelog now state the rule the code enforces, including the lower-case path components, which were written down nowhere. Signed-off-by: Max Freedom Pollard <[email protected]>
`_PUBLISHED_PASS` matched any line that contained "Overall verdict: PASS", because its `.*` ran straight past the list marker that opened the line. Every card carries the policy section, which restates the rule as a bullet, "- Overall verdict: PASS only when every configured dimension passes for at least one supported agent", so an INCOMPLETE or a FAIL card read as a published PASS. Under `--require-source-provenance` that card was then asked for the three provenance fields a publication claims, and the scan reported three offenders against a card that published nothing. A local INCOMPLETE card failed the strict scan on exactly those three lines, none of which it was answerable for. Both detectors now read a verdict line and nothing else. A list item is excluded at the line start, and the verdict has to close its line or introduce the wording that explains it, so the rendered callout, `> **Overall verdict: PASS` followed by the recommendation, still matches while the policy bullet does not. The two are built from one shared tail, so they cannot drift into disagreeing about what a verdict line is. `_PUBLISHED_PASS` still accepts a missing blockquote marker, which is how a hand-authored backfill card states its verdict, and `_OVERALL_PASS` still requires one. The strict scan of a tree of cards is the only place this was visible, so the tests scan the two shapes that matter: a non-PASS card carrying the policy bullet, which must raise no publication offence, and the published card, which carries the same bullet and must still be charged for provenance it omits. Signed-off-by: Max Freedom Pollard <[email protected]>
`_catalog_child_argv` had no caller anywhere in the tree, and the only reason `_catalog_child_argv_from_sys` existed was to be chosen by it. The parallel catalog path calls `_catalog_child_argv_from_ctx` directly, so both functions were a second answer to a question the code stopped asking. Keeping them was not free. The sys builder rebuilt a child command by scraping the parent argv, and it kept only the flags: anything that did not start with `-` was skipped, option values included. An option that takes a value therefore came out bare. Reinstated on this branch, `--evaluated-source-repository NVIDIA/demo` would have reached the child as the option alone, and Click would have read the appended `-o` as its value, leaving the output directory a stray argument. The identity this branch forwards to every catalog child would have gone missing on the way, and the cards would have said the evaluated source was never recorded. Deleting the builder closes that path rather than leaving it in reach of the next person who needs one. Signed-off-by: Max Freedom Pollard <[email protected]>
|
@rng1995 All six findings from the 12 September and 16 September passes are addressed, and I have resolved the threads. The CHANGELOG conflict with The three workflows on this PR are sitting at
The tests named in my replies all pass, 89 in total: Two limits on that local run, so you can weigh it accordingly. The Linux, Windows and RHEL 8 jobs cannot run on this machine, so the figures above are the macOS subset plus the lint and gate steps. One test fails locally, DCO is satisfied: every non-merge commit on the branch carries a The |
rng1995
left a comment
There was a problem hiding this comment.
Re-reviewed the latest head against all six inline findings. The strict container provenance rule, conflict folding across every carrier, canonical immutable revision validation, production/standalone/parallel identity plumbing, component-wise OCI reference validation, and pre-report finalization are all implemented and covered. Independent validation passed the 261 focused provenance/CLI/catalog tests, Ruff, the strict public-benchmark gate, and Fern docs checks. The broad suite produced 6,335 immediate passes; all 98 cases initially blocked by workspace socket/Seatbelt permissions or the host python3 path passed on controlled reruns with the required host permissions and project interpreter. The head merges cleanly with current main. GitHub fork workflows remain action_required pending separate maintainer authorization.
What this changes
A published
BENCHMARK.mdrecords the evaluator build but not the source tree it evaluated. As #72 shows, three skills from three different repositories published cards whose only recorded revision was0117bc2e…, the SkillEvaluator container tag they happened to share. A reviewer reading a committed card cannot tell which version of a skill was evaluated, or whether the card went stale after the source changed.This carries an evaluated-source identity through the result contract and renders it with labels that are unambiguously distinct from the evaluator provenance:
Design notes
The identity comes from the orchestration input, never from repository state. Per the issue's requirement, nothing is inferred at render time: the tree that renders a card is the evaluator checkout, not the evaluated source, so inferring it there would reproduce the bug being fixed. Four seams supply it:
--evaluated-source-repository,--evaluated-source-revisionand--evaluator-container-revisiononvalidateand ontier3 evaluate.validaterecords the identity on the card and in its JSON report and forwards it to every child of a parallel catalog run; both commands persist it into a Tier 3 run'srun_config.json. A value that is not canonical is refused at the CLI boundary rather than dropped;evaluated_sourceargument tobuild_agent_eval_payload/agent_eval_result_from_directory;evaluated_sourceobject in the run'srun_config.json;metadata["evaluated_source"]on any validation result.That last seam matters more than it looks: a card can publish a
PASSwithout a completed Tier 3 run (Tier 1-only, or an advisory Tier 3 skip undertier3_required = false). Those shapes have noagent_evalpayload to carry an identity, so without a result-level carrier the strict gate below would be permanently unsatisfiable for any catalog containing one. The renderer resolves the identity the same way_benchmark_policyalready resolves the persisted policy.Conflicting inputs fail rather than resolving by precedence. Acceptance criterion 3 asks publication to fail closed when provenance "is missing or conflicts with the orchestration input". Every carrier the run recorded (the CLI input, every result's metadata, and the top level and summary of every Tier 3 payload) is folded into one identity field by field, so a typo in one cannot silently discard a good identity in another, and a partial identity is completed rather than left to shadow the rest. A field two carriers supply with different values raises
EvaluatedSourceConflict.validateresolves the identity before any report is emitted, so a contradictory run writes nothing at all instead of a JSON report that disagrees with its card, and result ordering can never decide which source tree a card names.One definition, shared by producer, renderer and gate.
skillevaluator/source_identity.pyis a small dependency-free module holding the field rules. It sits alongsideevidence.pyandspdx.pyrather than underevaluation/orreporting/, becauseevaluationalready importsreportingand the renderer needs it too. (The CI script mirrors the shapes as literals rather than importing, since it is deliberately stdlib-only; a test asserts the mirrored pattern text is identical.)Revisions are accepted only in canonical shapes. A source revision is a full Git object id (40 or 64 hex characters) or a
sha256/sha384/sha512digest at its algorithm's width; a short prefix can grow a second match as a tree grows, so it is refused. A container revision is an OCI image reference validated component by component: an optional registry host (localhost, a dotted name, or a name with a port), a lowercase repository path of at most 255 characters, an optional tag of at most 128, and a digest at its algorithm's width. A bare image name is refused because it names no revision, and a publishedPASSmust pin the evaluator by digest or by a full implementation revision, because a tag can be repointed after the card is published.Validated values are published verbatim, and that is deliberate. An earlier draft ran the identity through
_publication_safe_inline. That escaping rewrites_to\_and@to@, which silently corruptedholgerroth/nvflare_examplesandghcr.io/…@sha256:…, turning the immutable, digest-pinned reference the issue asks for into a string resolving to nothing, which this patch's own gate then rejected. Validation is what makes a value safe, so the card emits the canonical value unchanged. Every accepted character is inert inside a code span, and the renderer re-validates rather than trusting the payload, so a card rendered from a hand-built or legacy metadata dict drops a hostile value instead of escaping it into the output. Digest algorithms are an allowlist (sha256/sha384/sha512), not a charset, sototally-fake:0000…cannot read as a canonical digest.revision, notcommit. The issue accepts "commit SHA, or a canonical digest of the evaluated skill content". A line labelledcommitcarrying asha256:digest would be its own small lie, so the line readsEvaluated source revision; a commit takes precedence when both are supplied.An absent identity says why. These fields fall back to
not recorded (not supplied by the orchestration input)rather than borrowing the existinglegacy or non-live resultwording, which would be false: a fresh live run with no orchestration input is neither legacy nor non-live.The gate check is opt-in, and this repository's CI enables it.
--require-source-provenancerequires the three fields and fails any card publishing aPASSwithout a recorded repository, revision and digest-pinned container, whatever the Tier 3 row says, and whether or not the verdict line is blockquoted (the rollout runbook points this flag at hand-authored backfill trees). Only a verdict line counts as a publishedPASS; the policy bullet that restates the rule on every card does not. Without the flag the scan is byte-compatible with its previous behaviour: cards generated before this contract existed cannot carry these fields.tests/test_public_benchmark_gate.pyis untouched by this diff, which is the evidence that default behaviour did not move.If you would rather it fail closed by default, say the word and I will flip it and update the fixtures here.
Scope
Two of the issue's acceptance criteria are deliberately not addressed:
output_provenance.pycovers atomic writes, not content signatures), so this belongs downstream.Git commitare renamed or regenerated": the renderer onmainalready stopped emitting the ambiguousGit commitfield, as the issue notes. Regenerating already-published downstream cards is outside this repo.I also left
_tier3_evidence_complete()alone: making the "Recommended for publication" decision depend on source provenance is a policy change with far wider blast radius than the CI gate.The unreachable argv-scraping catalog child builder (
_catalog_child_argv_from_sysand its dispatcher) is removed: it discarded every option value, so reinstating it would have silently dropped the provenance options this branch forwards.Tests
tests/test_benchmark_source_provenance.pyholds 157 tests covering normalization (including the container reference grammar on both the library and the gate), cross-channel resolution and conflict detection in both result orders, the payload contract, rendering, the non-Tier-3 carrier, both gate modes, themain()/find_offenders()entry points, a reporter→gate round-trip parametrized over the values that previously broke (_in a repository name, an@sha256:container reference, a content digest), the CLI supplying the identity, the JSON report carrying it, a conflict aborting before any report file exists, and a standalone run persisting it intorun_config.json.tests/test_commands.pyandtests/test_cli.pycover the parallel catalog children, a drift guard that fails when avalidateoption is neither forwarded to children nor explicitly excluded, and thetier3 evaluateoptions.It includes the regression the issue names, "two source repositories evaluated with the same evaluator image retain different source SHAs", and asserts the stronger property that actually matters: neither card's source revision ever equals the shared container revision, which is the exact confusion #72 reported. A companion test covers a run with no commit, checking the card says
not recordedrather than borrowing the evaluator's revision.Hostile-payload tests confirm that markdown, HTML and line-injection attempts through an unvalidated metadata dict are dropped rather than escaped into the card.
Verification
uv run python -m pytest -qon Python 3.12 and on 3.13: 6227 passed, 17 skippeduv run ruff check .: all checks passedscripts/ci/check_public_benchmarks.py --require-files tests/golden --require-source-provenance(the exact CI invocation): passesbenchmark_pass/benchmark_failregenerated; the fixture deliberately usesNVIDIA/demo_skillsand a digest-pinned container reference, so the two previously-corrupted characters are covered by the golden itselfOne note in passing, unrelated to this change:
make lintrunsruff check src testsand reports 54 pre-existing errors on a cleanmain, while CI runsruff check .and is green. The Makefile target may want to match CI.Closes #72