fix(validate): dedupe overlapping sources + deterministic diagnostic order (#746) - #759
fix(validate): dedupe overlapping sources + deterministic diagnostic order (#746)#759avrabe wants to merge 2 commits into
Conversation
…order (#746) Two independent friction items reported against v0.28.0, both traced to the same audit run: (1) a `rivet.yaml` that lists a directory source AND individual file sources inside it produced N phantom `duplicate-artifact-id` errors — one per id in the overlapped file, all with the SAME resolved path on both sides of the "declared more than once: A and B" message. In the reproducer 184 phantom errors buried 6 genuine cross-file collisions; the whole error class had been written off in CI as "phantom." (2) `rivet validate --format text` / `--format json` output flipped between runs on identical repo state, so a literal diff or `grep -c` on two reports gave wrong answers — the issue body reports one concrete instance where a category was reported as -10 when the section header (authoritative) showed +1. Root causes: - Source overlap: `ProjectContext::load` and `cmd_validate`'s duplicate scan iterate `config.sources` verbatim, so a file reachable via two entries loads twice. `detect_duplicate_ids` compared by id only and never asked whether both "first" and "second" pointed at the same resolved path, so every id in the overlapped file surfaced as a same-path collision, drowning real cross-file ones. - Nondeterministic output: eight `for artifact in store.iter()` sites in `validate.rs` walked the backing `HashMap`, so the diagnostic vec inherited hasher-seed order. `LifecycleGap.missing` was a `HashSet`-derived `Vec`, same problem, hitting the "missing: X, Y" render at `main.rs:6358` and the JSON `missing` array at 6209. Fix: - `rivet-core::detect_source_overlaps(config, base_dir) -> Vec<SourceOverlap>` canonicalizes each `sources[]` path and reports pairs where one contains (or equals) the other, in deterministic `(outer_index, inner_index)` order. - `cmd_validate` emits one `overlapping-source` Warning per pair, pointing at `rivet.yaml` — the diagnostic the user can act on — instead of N per-id errors pointing at the overlapped file. - `rivet-core::detect_duplicate_ids_excluding_overlaps(artifacts, overlapped)` suppresses self-collisions ONLY when both sides resolve to the same path AND that path is a member of the caller-supplied overlapped-file set. Genuine within-file duplicates in a non-overlapped file still fire; REQ-081's needs.json duplicate-inner-id guard is unaffected (verified: same_source_files-both-None case also still fires, and cross-file collisions still fire — regression-tested). - `LifecycleGap.missing` sorted at construction. - Eight `store.iter()` sites in `validate.rs` swapped for `iter_sorted()` — matches the REQ-159/#415 sweep convention and the doc-comment on `Store::iter()` itself ("any caller that produces stable output ... MUST sort"). Reproduced and regression-tested: - rivet-core unit: `detect_duplicate_ids` unconditional-fire preserved; `excluding_overlaps` suppresses only overlapped self-collisions; cross-file, both-None, and non-overlapped-file cases all still fire. - rivet-core unit: `detect_source_overlaps` flags same-resolved-path, directory-containing-file, and ignores disjoint sources. - rivet-core unit: `LifecycleGap.missing` sorted alphabetically for byte-stable reports. - rivet-cli integration: `validate_reports_source_overlap_once_and_suppresses_self_collisions` end-to-end (init project → write overlapping rivet.yaml → assert one `overlapping-source` warning + zero `duplicate-artifact-id` errors). - rivet-cli integration: `validate_output_is_deterministic_across_runs` runs `validate --format json` three times, asserts byte-identical stdout across all three. Full suite: rivet-core 1192/1192 unit + integration/proptest/yaml-test-suite green; rivet-cli 161/161 cli_commands + all other integration tests green. `cargo fmt --check` clean; `cargo clippy --all-targets -- -D warnings` clean (only the pre-existing clippy.toml MSRV note). `rivet validate` PASS on the repo itself. Fixes: REQ-004 Refs: REQ-159, #746
📐 Rivet artifact deltaNo artifact changes in this PR. Code-only changes (renderer, CLI wiring, tests) don't touch the artifact graph. |
|
CI status heads-up —
Deliberately not pushing an ignore-list update on this branch — that's a Same run also shows Every other check on this PR is green so far (Format, Semver setup, Cargo Deny, cargo-vet, MSRV 1.89, Verus Proofs, Rocq Proofs, YAML Lint, Schema version bump, Zola export smoke, Detect changed areas, Traceability rivet validate step ✅). Watching for Test / Clippy / Miri / Kani / Docs / Traceability full completion. Generated by Claude Code |
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
|
Needs a rebase — now Two notes for when it's picked up:
|
…overlap-dedup-and-determinism
|
Rebased — merge commit 2a1f40b, brings the branch up to Auto-merge, no manual conflict resolution needed — the
Verified my patch is still fully applied: Full gate green on the merged tree:
Acknowledging your two notes:
Generated by Claude Code |
There was a problem hiding this comment.
⚠️ Performance Alert ⚠️
Possible performance regression was detected for benchmark 'Rivet Criterion Benchmarks'.
Benchmark result of this commit is worse than the previous benchmark result exceeding threshold 1.20.
| Benchmark suite | Current: 2a1f40b | Previous: b42a89e | Ratio |
|---|---|---|---|
query/10000 |
297103 ns/iter (± 1347) |
222696 ns/iter (± 3110) |
1.33 |
This comment was automatically generated by workflow using github-action-benchmark.
|
On the
pub fn execute<'a>(store: &'a Store, query: &Query) -> Vec<&'a Artifact> {
let mut results: Vec<&Artifact> = store.iter().filter(|a| query.matches(a)).collect();
results.sort_by(|a, b| a.id.cmp(&b.id));
results
}That path walks Zero diff on Two plausible non-patch causes:
I could try to bisect between the six changed files locally, but running Criterion at n=10000 with enough samples to distinguish real regression from codegen wobble takes ~10 min and doesn't guarantee reproducibility on a different runner. Happy to dig if you'd like; otherwise a re-run of the benchmark job on the same commit would settle whether it's variance. The alert is a warning comment from the benchmark action, not a check-run failure — CI doesn't gate on it. Generated by Claude Code |
Closes #746.
Two independent friction items from the same audit run on
spar(v0.28.0):rivet.yamlsources made every id collide with itself. A config that lists a directory source AND individual file sources inside it (the way to mix formats per-file, since directory sources apply one format everywhere) loads each covered file twice.detect_duplicate_idscompared by id only, so every id in the overlapped file surfaced as aduplicate-artifact-idError with the same resolved path on both sides of the "declared more than once: A and B" message — the message reads like a file-content bug, so the search goes to the artifacts, which are fine. In the reproducer: 184 phantom errors buried 6 genuine cross-file collisions; the whole error class had been written off in CI as "phantom errors" — which is why the real ones went unnoticed for months.rivet validateoutput was non-deterministic across runs.LifecycleGap.missingcame from aHashSet, somissing: design-decision, featureon one run andmissing: feature, design-decisionon the next. Eightfor artifact in store.iter()sites invalidate.rswalked the backingHashMap, so the diagnostic vec inherited hasher-seed order; a six-id group emitted as001, 005, 003, 002, 004, 006. The issue body reports one concrete instance where a category wasgrep -c'd as -10 when the section header (authoritative) said +1.Root causes
ProjectContext::loadandcmd_validate's duplicate-scan collector iterateconfig.sourcesverbatim, so a file reachable via two entries loads twice.detect_duplicate_idsnever asked whether both "first" and "second" resolved to the same on-disk path.store.iter()'s doc-comment already says "any caller that produces stable output MUST sort" (REQ-159 / Store::iter() is HashMap-ordered (nondeterministic) — selection/output over it is a latent flakiness footgun #415 was the sweep that established the pattern); thevalidate.rssites simply weren't part of that sweep, andLifecycleGap.missingwas hitting the same footgun through a HashSet.Fix
rivet-core::detect_source_overlaps(config, base_dir) -> Vec<SourceOverlap>— canonicalizes eachsources[]path and reports pairs where one contains (or equals) the other, in deterministic(outer_index, inner_index)order.cmd_validateemits oneoverlapping-sourceWarning per pair, pointing atrivet.yaml— the diagnostic the user can act on — instead of N per-id errors pointing at the overlapped file.rivet-core::detect_duplicate_ids_excluding_overlaps(artifacts, overlapped)— suppresses self-collisions only when both sides resolve to the same path and that path is a member of the caller-supplied overlapped-file set. A within-file duplicate in a non-overlapped file is still a real bug and still fires; cross-file collisions still fire; REQ-081's needs.json duplicate-inner-id guard (which callsdetect_duplicate_ids_for_validateon artifacts withoutsource_filestamps) is unaffected.LifecycleGap.missing— sorted at construction, so--format text(.join(", ")) and--format json(raw array) are byte-stable.store.iter()sites inrivet-core/src/validate.rs→iter_sorted(). Matches the REQ-159/Store::iter() is HashMap-ordered (nondeterministic) — selection/output over it is a latent flakiness footgun #415 convention. One remainingstore.iter()(line 1294) is already followed byitems.sort_by(|a, b| a.0.cmp(b.0))so its downstream is deterministic; leaving it alone.Acceptance criteria (from the issue body) → how satisfied
formatoverride, or anexclude:on a directory source") rather than solved here.overlapping-sourceWarning; message points atrivet.yaml, names both source indices, both paths, and both formats. Regression-tested end-to-end (validate_reports_source_overlap_once_and_suppresses_self_collisions).missing:type list, artifact iteration in--format text, and any other set-derived output. Byte-identical output for identical input makes report diffing a reliable review tool. (--format jsonwould benefit from the same guarantee, for the same reason.) —LifecycleGap.missingsorted at construction (feeds both text and JSON simultaneously); eightstore.iter()sites invalidate.rsswapped foriter_sorted(). End-to-end regression test runsvalidate --format jsonthree times and asserts byte-identical stdout across all three.Test plan
cargo test -p rivet-core --lib— 1192/1192 pass (up from 1184, +8 new)cargo test -p rivet-core --tests— every integration / proptest / YAML-test-suite target greencargo test -p rivet-cli --tests— every CLI integration target green, 161/161 on cli_commands including the 2 new end-to-end testscargo fmt --all -- --checkcleancargo clippy --all-targets -- -D warningsclean (only the pre-existingclippy.tomlMSRV note)cargo run -p rivet-cli --release -- validate— PASS (no change in exit; the repo has no overlapping sources, so no new warnings from this PR)What this PR is NOT
formatoverride affordance the issue itself calls out as the reason the overlap exists.detect_duplicate_ids_for_validatepublic entrypoint used by that guard has unchanged semantics.--format jsonschema. Field names / structure preserved; only the order of arrays that used to be nondeterministic is now sorted.Follow-ups (deliberately out of scope)
formatoverride or anexclude:on a directory source (the issue's own suggestion), which would let the user express "scan this dir as stpa-yaml except these files" without needing overlapping sources.for source in &config.sourcesloops so a covered file loads exactly once.store.iter()call invalidate.rs:1294(already deterministic downstream via aBTreeMap+.sort_by) could be converted toiter_sorted()on style grounds; not touched to keep the diff minimal.Generated by Claude Code