Repository navigation
Conversation
…epeated runs context consistency reads N stream-json traces of one task (or several arms) and reports three separate axes instead of one determinism score: result (success, pass@k, pass^k), path (tool-mix JSD, order/action edit distance, resource CV, strategy groups with per-group verdicts) and conformance to an optional YAML process contract (requires, forbids, before, after_last, last_ok). No model call. Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
One Bash call becomes one step per chained command (quote- and heredoc-aware), and shell writes (sed -i, >, tee) carry their file, so an edit or check done through the shell counts. A blocked Stop hook is a failed Hook:Stop step. --shell call keeps the per-tool-call grain. Docs: contract equivalence and fault exposure. Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
…distrusts piped checks A failed shell call marked only its last chained command as failed, so a failed `make check && git diff` passed last_ok. Every command of a failed call is now failed: the trace does not say which one was. `pytest | tail` reports tail's status. A step piped without pipefail is now masked, and last_ok does not accept a masked step as a passing check. _written tokenises with shlex punctuation_chars, so a `>` or `|` inside quotes is no longer a write or a pipe. Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
|
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #155 +/- ##
==========================================
- Coverage 91.54% 91.48% -0.06%
==========================================
Files 222 223 +1
Lines 27369 27908 +539
Branches 4505 4632 +127
==========================================
+ Hits 25054 25532 +478
- Misses 1644 1678 +34
- Partials 671 698 +27
🚀 New features to boost your workflow:
|
`repo compare --pass-k K` adds pass^K per task, C(c,K)/C(n,K) over its cells, so an arm that makes a task pass every time is told apart from one that only moves the rate. `--path-stability` adds path_distance, the mean order distance of the cells' tool paths read from events.jsonl through context.consistency (run_from_events, order_distance). Both are descriptive and stay out of Holm's family. Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
…ment Add the license header and the mkdocs nav entry of run-consistency.md, raise the benchmark's max_docs to 31 for it, add the changelog fragment, and rename the trace-grain parameter from shell to grain so bandit does not read shell="call" as a subprocess call. Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
antonwolfy
left a comment
There was a problem hiding this comment.
Review at 8b8bd09. The metrics (pass@k, pass^k, JSD, order distance, the C(c,K)/C(n,K) form in repo compare) are right, and the default repo compare output is unchanged. The problems are in the shell-step parsing, in how empty or unreadable input is reported, and in the strength of the tests. Inline comments cover the first ones; the test gap is below.
What I ran, on a Linux review host at 8b8bd09:
make ci-local: 24 passed, 2 failed, 6 skipped. 6759 tests passed, 1 failed:test_unbalanced_quote_in_hook_completes_quickly(1.25 s against a 1 s limit, under 8-worker load).pip-auditreports virtualenv 21.14.7 (CVE-2026-113722) from the existing locks, fixed onmainby #152. Neither comes from this PR. The changed-line coverage gate passed.- Reproductions for the code findings, with the LLM transport mocked (none of this code calls a model).
- A mutation run over the two new test files: 49 mutants applied, 21 pass the full suite.
Tests do not pin several rules. tests/test_context_consistency.py and tests/test_repo_compare.py survive these one-line mutants:
forbids:ok = not achanged took = Truepasses. The test contract hasforbids: push, but no fixture run pushes.before/after_last:a[0] < b[0]changed to<=, anda[-1]changed toa[0], both pass.passed_but_nonconforming: dropping ther.passed andcondition passes.- Metric denominators:
success_rateandpass^koverninstead ofjudgedpass, because no fixture has an unjudged run. run_from_events:rc != 0->Falseanddenied->Falseboth pass, so failure marking for--path-stabilityis untested.repo compare:if pass_k and not score_taskchanged toif pass_kpasses (no score-task test);order_distance(runs[:2])instead of all runs passes (the tests use two cells per arm);except ValueError: continuechanged toraisepasses.
Add a fixture per rule where the rule is violated, one arm with an unjudged run, and a three-cell path case.
Checked, no issues
- Metrics:
pass_at/pass_hatare guarded byk <= judged, JSD stays in 0..1, and the single-linkage grouping and CV are safe on zero or one sample. - Holm: the new measures are outside
PRIMARY, so the family size is unchanged. With the flags absent noevents.jsonlis read. - Repo rules: the new page is in
mkdocs.ymlnav and the docs index, the changelog fragment has the scriv format, no schema orSCHEMA_VERSIONSentry applies, and no generated files are committed. - Docs vs code: every flag and metric name in the docs matches the CLI help and the code.
| return [Event(tool, kind, f"{kind}({target})" if target else kind, args)] | ||
|
|
||
|
|
||
| def _segments(command: str) -> list[str]: |
There was a problem hiding this comment.
Shell control syntax becomes path steps, and two _written checks are wrong. _segments splits on ; and && without parsing compound commands, so keywords turn into steps (outputs of parse_run on one Bash call):
for f in a b; do black $f; done->Bash:for,Bash:do,Bash:done. There is noBash:black.if [ -f x ]; then make; fi->Bash:if,Bash:then,Bash:fi. There is noBash:make.(cd sub && pytest)->Bash:(cd,Bash:pytest).# note && x+ newline +pytest->Bash:x,Bash:pytest.
A contract rule that names Bash:make cannot match, and order_distance / action_distance / mix_jsd count the keywords, so identical strategies split.
In _written (consistency.py:274, outside this hunk's anchor): the words[i-1].isdigit() guard meant to skip 2> also skips echo 3 > count.txt and seq 1 10 > out.txt (no file_path). The in-place regex counts perl -Mstrict -e 'print 1' foo.pl as an edit of foo.pl. sed --in-place 's/a/b/' f.py is not seen as an edit.
Suggestion: drop the shell reserved words and leading ( / trailing ) in _program, treat only 2>, 2>> and &>-style fd redirects as non-writes, and match -i / --in-place flags exactly.
There was a problem hiding this comment.
Fixed in 911a507. Compound syntax is stripped from steps (for/do/done, if/then/else/fi, while, leading ( and trailing ), [ tests) and comments are dropped before splitting on && and ;. _written now treats only a digit glued to the operator (2>, 2>>) as an fd redirect, so echo 3 > count.txt writes, and the in-place flag is matched per tool: sed -i/-i.bak/-ni/--in-place, perl -pi, but not perl -Mstrict. Tests: test_control_syntax_is_not_a_step, test_written_review_cases.
There was a problem hiding this comment.
Thanks, the reserved words are no longer steps or programs, and _written is right now. The action and command text still keep them, though: for f in a; do black x; done gives Bash:black(do black x) against Bash:black(black x) for the bare command, seq_distance 1.0, and a command: ^black matcher misses the loop step. Details in the review body.
Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
|
@napetrov It passed at 8b8bd09 because Suggestion: in |
Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
…efuses ambiguous outcomes; compare skips empty traces Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
|
Review at 8b8bd09, test gaps, all pinned in 911a507 (each checked by applying the one-line mutant and seeing a test fail):
|
antonwolfy
left a comment
There was a problem hiding this comment.
Review at 911a507. The fixes for the earlier threads hold: the "no runs" verdict, the clean error for malformed --outcomes, the refusal of a shared outcome key, the empty-trace and nested-line cases in repo compare, the max_docs bump and the --reps wording. The shell-step parser is still the weak part, and the new shell-edit rule has a side effect. Six new points below, plus one thread still open.
What I ran, on a Linux review host at 911a507:
make ci-local: 27 passed, 1 failed, 7 skipped. 7220 tests passed. The one failure ispip-audit(gitpython 3.2.0, CVE-2026-114145, fixed in 3.2.1) from the existing locks; the PR changes no lock file. The changed-line coverage gate passed.- Reproductions for the code findings, with no model involved (none of this code calls one).
- A mutation run over the two new test files: 72 mutants, 29 pass the full suite, 6 of those equivalent. The ones that matter are in point 2 below.
Still open from my earlier review:
dokimi/dokimi/context/consistency.py:207: the program name is cleaned, but the step's action andcommandstill carry the syntax words.for f in a; do black x; donegives the actionBash:black(do black x), a plainblack xgivesBash:black(black x), andseq_distancebetween them is 1.0. Soaction_distancestill splits identical strategies, and a matchercommand: ^blackdoes not match the loop step ([False]against[True]for the bare command). The same holds forif pytest -q; then ...; fi(Bash:pytest(if pytest -q)) and(cd x && pytest -q)(Bash:pytest(pytest))). Suggestion: build the action andcommandtext from the words after_strip_syntax, not from the raw segment.
Findings that cannot be anchored inline:
-
repo compare --pass-k/--path-stabilityfloodunpairedwith entries that are not unpaired tasks.compare.py:292-296adds an entry for every task that lacks the measure: score tasks (pass^K is gate-only), tasks with fewer than K cells, and tasks with fewer than two traced cells. The reason reads "no pass^2 in baseline or treatment", which is wrong for a score task.repo rundefaults to--reps 1, so--path-stabilityon a default batch lists every task. Repro: 15 tasks with one cell per arm plus one task only in the baseline;compare(..., pass_k=2, path_stability=True)returns 31 unpaired entries, and the text shows ten "no pass^2 in baseline or treatment" lines and "and 21 more". Without the flags the same batch prints one line,zz_only_base (m-1, no cells in treatment), the only real one. Suggestion: keep a task out ofunpairedwhen the measure does not apply to it, and report the count of tasks without the measure in one line. -
Tests still do not pin several rules added in the fixes. Mutation check, one-line mutants that pass both new test files (140 tests):
Matcher.__call__: droppingand set(self.tools) & _WRITE_TOOLS(sotool: Readalso matches a shell write) passes.parse_run:except (ValueError, RecursionError)changed toexcept ValueErrorpasses; only_cell_pathhas the nested-line test. A trace line of"[" * 100000still abortscontext consistency._written: droppingprev != "1"or the(?<!\S)lookbehind in_fd_redirect, removing">|"from the operators,len(files) >= 2changed to>= 1(sosed -i 's/a/b/'with no file reports a file), andteewithout the pipe-or-start condition (echo tee out.txtbecomes a write) all pass.apply_outcomes: removing the stem match passes.load_outcomes: acceptingintvalues passes.Run.shape:if not out or out[-1] != kchanged toif Truepasses, so nothing checks that repeats collapse.
Add one case per line:_written("echo tee out.txt") == "",_written("sed -i 's/a/b/'") == "",_written("echo x >| f") == "f", a Read matcher against ased -istep, a stem key fordir/run1.jsonl,load_outcomeson{"a": 1},parse_runon a 200000-bracket line, and runsRead Read Read Edit/Read Editas one strategy.
| return [Event(tool, kind, f"{kind}({target})" if target else kind, args)] | ||
|
|
||
|
|
||
| def _segments(command: str) -> list[str]: |
There was a problem hiding this comment.
_segments still mis-splits common shell: \;, $(...), backticks, & and case. It tracks quotes but not escapes outside them, nesting, or &. Outputs of _events("Bash", {"command": ...}):
find . -name '*.pyc' -exec rm {} \; -printgivesBash:find(find ... {} \)with a dangling backslash, plus a bogus stepBash:-print.echo a\;b; lsgivesBash:echo(echo a\),Bash:b(b),Bash:ls.echo don\'t; lstakes\'as an opening quote, solsis swallowed: one step.x=$(echo a && echo b); pytestgivesBash:a(x=$(echo a),Bash:echo(echo b)),Bash:pytest.x=$(git rev-parse HEAD; ls) && pytestgives the programrev-parse.make & pytestis onemakestep andpytestis lost.case $x in a) make ;; b) ls ;; esacgives one step,Bash:b); themakebranch is gone. The multi-line form givesBash:a)andBash:b).
The bogus steps change order_distance / action_distance and break requires / before for a rule that names the real command. find -exec ... \; is in most agent traces.
Suggestion: walk the command once with a small state machine (backslash outside quotes, $(/backtick depth, & as a separator when not part of &&, >&, &>) and treat case labels like the other reserved words. Add one test per case above.
| if self.tools and ev.tool not in self.tools and ev.kind not in self.tools: | ||
| # a shell step that writes a file (`sed -i`, `>`, `tee`) is an edit for a matcher of edit tools | ||
| if not (ev.tool == "Bash" and ev.input.get("file_path") and set(self.tools) & _WRITE_TOOLS): |
There was a problem hiding this comment.
Any shell redirect or tee now counts as an edit, so a contract that forbids or orders edit fires on test runs. The rule added for the edit action matches every Bash step with a file_path, and _written sets file_path for any > / tee target, not only for source edits. Matcher(tools=("Edit",)) returns True for:
pytest > test.logpytest 2>&1 | tee test-output.txtgit diff > changes.patch
So forbids: edit reports a violation for a run that only logged its output, and before: [guide, edit] is judged against the log write. The step's action is also rewritten to Bash:pytest(test-output.txt), which hides the command. The opposite miss: make &> build.log and make &>/dev/null have &> in _OPERATORS but are not seen by the write detection.
Suggestion: keep two things apart. Let file_path mean an in-place edit (sed -i, perl -pi) or a redirect to a path that is not a log or /dev/*, or give the step a separate writes field and let the edit rule use only the in-place forms; document the choice in docs/run-consistency.md. Add a test that pytest > test.log does not match edit.
| raise ValueError(f"{where}: `rules:` wants a list") | ||
| for i, rule in enumerate(raw_rules): | ||
| if not isinstance(rule, dict) or len(rule) != 1 or next(iter(rule)) not in RULES: | ||
| raise ValueError(f"{where}: rule {i} must be one of {', '.join(RULES)}") |
There was a problem hiding this comment.
A scalar or nested rule argument still ends in a traceback, so the clean-error fix for --contract is incomplete. parse_contract calls list(arg or []) here and tuple(str(t) for t in tools) at line 424. The CLI catches only OSError and ValueError.
rules: [{requires: 5}]raisesTypeError: 'int' object is not iterable.rules: [{requires: [[a]]}]raisesTypeError: unhashable type: 'list'.actions: {a: {tool: 5}}raisesTypeError: 'int' object is not iterable.requires: {a: 1}is accepted and uses the dict keys as names.
It is the same failure class as the earlier thread (the traceback instead of ✗ ...), reached through a different input.
Suggestion: check that arg is a string or a list of strings, and that tool is a string or a list of strings, and raise ValueError(f"{where}: ...") otherwise. Add the three inputs above to test_malformed_outcomes_and_contract_are_value_errors.
| judged = [r for r in runs if r.passed is not None] | ||
| c = sum(1 for r in judged if r.passed) | ||
| result: dict[str, Any] = {"runs": n, "judged": len(judged), "passed": c, | ||
| "success_rate": c / len(judged) if judged else None} |
There was a problem hiding this comment.
A run with no result event (crashed, timed out, truncated) drops out of success_rate, pass@k and pass^k instead of counting as a failure. parse_run leaves passed=None without a result event, and profile keeps only judged runs. The --outcomes path warns about unjudged runs; this path does not, and the text table shows runs but not judged.
Repro: four traces, one ending with a result success, three with tool calls and no result event. profile(...)["result"] gives {'runs': 4, 'judged': 1, 'passed': 1, 'success_rate': 1.0, 'pass@1': 1.0, 'pass^1': 1.0}. An arm where three of four runs died reads as 100% reliable. That is survivorship bias in the metric the PR is built to report.
Suggestion: when no --outcomes is given, count a trace that has events but no result event as failed (passed = False), or at least print a warning with the number of unjudged runs and show judged in the table. Add a test with a truncated trace.
Summary
New
context consistency: reads repeated Claude Codestream-jsontraces of one task and answers three questions apart. No model calls.success_rate,pass@k,pass^kmix_jsd,order_distance,action_distance, CV of calls/turns/tokensconformance_rate, violations per rule,passed_but_nonconformingThird commit fixes three bugs found while using it:
Fourth commit wires it into
repo compare(item 4 of NEXT_STEPS), opt-in, descriptive, outside Holm:events.jsonlis harness-agnostic, so path stability works for any harnessrepo runconverts (consistency.run_from_events).Docs:
docs/run-consistency.md,docs/repo-evaluation-contract.md.Evidence
sededits that call grain hid.make check && git difffailing →check_contract == [];pytest -q | tail -5→[].After:
["last_ok: test"];["last_ok: test (status hidden by a pipe)"]; withset -o pipefail→[].repo comparetest: t1 2/3→2/3, t2 2/3→3/3: gate delta moves one task by 1/3;pass^3shows baseline 0.0 vs treatment 0.5 (t2 became reliable). Path test: baseline cellsgrep,pytestvsgit,make→ 1.0; treatmentgrep && pytestvsgrep; pytest→ 0.0.test_reach_timeout_kills_the_harness_process_tree[orphan]failure under-n autoload; passes alone and on main.)Merge Danger
Door: two-way
New subcommand and module;
repo comparegains two opt-in flags, default output unchanged.Blast Radius: small
🤖 Generated with Claude Code