Skip to content

fix(cli): propagate benchmark failures and isolate benchmark runs - #294

Merged
loveRhythm1990 merged 2 commits into
matrixorigin:mainfrom
loveRhythm1990:fix/benchmark-error-propagation-and-isolation
Oct 10, 2026
Merged

loveRhythm1990 merged 2 commits into
matrixorigin:mainfrom
loveRhythm1990:fix/benchmark-error-propagation-and-isolation

Conversation

@loveRhythm1990

Copy link
Copy Markdown
Collaborator

Summary

Three benchmark defects that all let a run report success it did not earn. Grouped into one PR because they overlap in benchmark_executor.rs / main.rs.

Report I/O — #270. cmd_benchmark wrote --out with unwrap_or_else(|e| eprintln!(…)) and then printed Saved unconditionally, returning unit, so a failed write still exited 0. Now the error propagates and Saved is only printed after a successful write.

Error accounting — #271. store() parsed the body without error_for_status and turned a missing memory_id into a successful empty string, so a 400/500 seed or store step counted as a success. retrieve() collapsed transport/HTTP/parse failures into an empty vector — indistinguishable from a valid empty answer — and the retrieve step could never fail. Both now return errors, the retrieve step propagates them, run_assertion records them, and an errored assertion scores zero rather than passing on empty expectations (recall and noise_rejection both default to 100 when a scenario declares no expected/excluded contents, so a service failure could previously pass outright). Failures are now printed as diagnostics instead of being discarded into never-read fields.

Run isolation — #273. run_id came from epoch seconds with no random component, so independent runs started in the same second shared a user namespace and could purge/correct each other's memories. It now mixes in random bits. Separately, validation checked scenario_id uniqueness case-sensitively while the executor lowercases it to build the namespace, so CaseA/casea validated as distinct and then collided; uniqueness is now checked on that same normalized form.

Verification

Reran each issue's own reproduction against the built binary.

#271 — loopback server injecting the issue's status matrix:

Store Retrieve Before After
200 200 100 / S, 100% steps 100 / S, no diagnostics (control unchanged)
400 200 100 / S, 100% steps 0.0 / D + ✗ setup failed: … 400 Bad Request
500 200 100 / S, 100% steps 0.0 / D + ✗ setup failed: … 500
200 500 39.17 / D, 100% steps 8.8 / D (store step genuinely passed) + step and assertion diagnostics
500 500 39.17 / D, 100% steps 0.0 / D + ✗ setup failed: … 500

#273 — CaseA + casea now fails --validate-only with exit 1 (duplicate scenario_id (case-insensitive): casea); four concurrent runs of one scenario now produce 4 distinct namespaces instead of 1.

#270 — --out <dir> → exit 1, Is a directory (os error 21), no Saved; --out <missing>/report.json → exit 1, No such file or directory (os error 2), no Saved; writable file → exit 0, Saved, valid report JSON.

Note the benchmark still exits 0 when scenarios fail against a live service — a bad score is a legitimate outcome — but the failures are now visible on stderr and scored correctly. Only the --out write failure changes the exit status.

Fixes #270
Fixes #271
Fixes #273

Test plan

  • cargo test -p memoria-cli passes (21/21 — 18 pre-existing plus 3 new: case-insensitive duplicate rejected, genuinely distinct ids still accepted, errored assertion scores 0 while a genuinely empty answer still scores 100)
  • All three issue reproductions re-run end-to-end against the built binary (tables above)

🤖 Generated with Claude Code

Three benchmark defects, all of which let a run report success it did
not earn:

Report I/O (matrixorigin#270): cmd_benchmark wrote --out with
`unwrap_or_else(|e| eprintln!(..))` and then printed "Saved"
unconditionally, returning unit, so a failed write still exited 0.
Scripts that archive reports by exit status silently kept nothing.
Propagate the error and only print "Saved" after the write succeeds.

Error accounting (matrixorigin#271): store() parsed the body without
error_for_status and turned a missing memory_id into a successful empty
string, so a 400/500 seed or store step counted as a success — a
scenario could score 100/S with its setup entirely broken. retrieve()
collapsed transport, HTTP and parse failures into an empty vector,
indistinguishable from a valid empty answer, and the retrieve step could
never fail. Both now return errors; the retrieve step propagates them;
run_assertion records them; and an errored assertion scores zero instead
of passing on empty expectations (recall and noise default to 100 when a
scenario declares no expected/excluded contents). Failures are also
printed as diagnostics instead of being discarded into unread fields.

Run isolation (matrixorigin#273): run_id came from epoch *seconds* with no random
component, so independent runs started in the same second shared a user
namespace and could purge or correct each other's memories. It now mixes
in random bits. Separately, validation checked scenario_id uniqueness
case-sensitively while the executor lowercases it to build the
namespace, so `CaseA` and `casea` validated as distinct and then
collided; uniqueness is now checked on the same normalized form.

Fixes matrixorigin#270
Fixes matrixorigin#271
Fixes matrixorigin#273

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@loveRhythm1990

Copy link
Copy Markdown
Collaborator Author

Reviewed head b007e9ef42c251585e7b58567a9cc91911cba4b3.

Two medium-severity findings remain:

  1. [P2] Enforce normalized scenario-ID uniqueness during ordinary benchmark execution. The new case-insensitive duplicate check in benchmark.rs is only invoked through --validate-only; ordinary execution still deserializes and executes directly. I ran a dataset containing CaseA and casea: validation rejected it, but the ordinary benchmark exited successfully and both store requests used the same X-Impersonate-User and session namespace. Thus the scenario-isolation fix can still be bypassed by the normal command, allowing one scenario's writes/purge/correction to affect the other. Run this validation before execution regardless of the flag, or make the execution namespaces collision-free for these IDs.

  2. [P2] Reject malformed retrieval entries instead of silently filtering them out. The top-level response shape is now checked, but the item conversion still uses filter_map on content.as_str(). With HTTP 200 and [{"content":"memory"},{"memory_id":"broken"}], a scenario expecting memory received overall score 100.0 and step_success 100.0, exited 0, and emitted no error. The invalid item is silently discarded, so a malformed backend response can still produce a perfect score. Validate every item's required string content and propagate an error through the new failure path when any item is invalid.

Validation: all 21 CLI tests passed on this head. The above cases were reproduced using the built CLI and a local HTTP fixture. Controls also confirmed that retrieval HTTP 500 produces failed/zero-scored results with diagnostics, an unwritable output destination exits nonzero without reporting success, and four concurrent runs receive distinct run namespaces. These checks did not use a live MatrixOne database.

Coverage: 5/5 changed files reviewed; 0 skipped (100%).

…d items

Follow-up to review on this PR; both findings reproduced before fixing.

The case-insensitive scenario_id check only ran under --validate-only, so
an ordinary `memoria benchmark` still executed a dataset containing both
`CaseA` and `casea` under one namespace (verified: two scenarios, one
X-Impersonate-User, exit 0). The invariant is now a reusable
`colliding_scenario_namespaces` helper that cmd_benchmark enforces before
executing anything, so the isolation fix cannot be bypassed by omitting
the flag. Only this invariant is enforced pre-run, not all of
validate_dataset, to avoid failing datasets over unrelated warnings that
run today.

retrieve() checked the top-level response shape but still used filter_map
over each item's `content`, so an entry without a string content was
silently discarded: a 200 returning
`[{"content":"memory"},{"memory_id":"broken"}]` scored Overall 100.0 (S)
with no diagnostics. Every item is now required to carry a string
`content`, and a malformed one propagates through the failure path added
in this PR (same case now scores 17.5 (D) with a diagnostic).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@loveRhythm1990

Copy link
Copy Markdown
Collaborator Author

lgtm

Re-reviewed 9ef4d5b87e20d4fecc04a08c554ec75f2fd3d33d. Both previous findings are resolved: ordinary execution enforces normalized scenario-ID uniqueness before creating the executor, and retrieval now rejects any entry without string content instead of silently dropping it.

Validation: cargo test -p memoria-cli passed, 22/22 tests; CLI build passed. Re-ran both original reproductions with the built CLI and a local HTTP fixture: CaseA/casea exits 1 before any HTTP request, and a malformed retrieval entry generates step/assertion diagnostics with overall score and step success both 0.0. No live database was used.

Coverage: 5/5 changed files reviewed; 0 skipped (100%).

@loveRhythm1990
loveRhythm1990 merged commit ce7ca8d into matrixorigin:main Oct 10, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant