Repository navigation
Conversation
7553be6 to
7c2af1d
Compare
oblinder
left a comment
There was a problem hiding this comment.
Code review: three findings, all in the new improvement-recommendations code. All three are plausible: I traced each one through the code, but I did not reproduce them. The changes are eval-only, so no product code is affected.
The move of the dashboard and trend log into eval/dashboard/ looks correct: imports, default paths, the .gitignore entry and the python -m eval.dashboard.dashboard command all match the new layout.
Suggested before merge: the None guard at recommendations.py:336 (one-line fix) and a check on the new trend_log.jsonl row. The other two findings can go to a follow-up issue.
| raise_sanitized(err) | ||
| except LLMError: | ||
| return [] | ||
| return result.patterns |
There was a problem hiding this comment.
result.patterns is read outside the try. If the structured-output call returns None (for example, the model answers in plain text), this raises AttributeError instead of returning []. The fallback grouping is then skipped, and the broad except in pytest_sessionfinish removes the whole recommendations section.
Suggested fix: return [] when result is None, or move the attribute access into the try.
There was a problem hiding this comment.
Fixed — _draft_patterns now returns [] when result is None, before touching .patterns (commit c301a57).
|
|
||
| recommendations: list[Recommendation] = [] | ||
| for pattern in patterns: | ||
| covered_ids = [case_id for case_id in pattern.case_ids if case_id in cases_by_id] |
There was a problem hiding this comment.
The fallback grouping runs only when no LLM pattern survives validation. If the LLM covers only some of the failing cases, or gives a made-up case id for one of them, the uncovered cases are dropped from the section and nothing says so. This goes against the module docstring, which says the section is "never silently blank".
Suggested fix: after this loop, collect the case ids that no pattern covers and send them through _fallback_recommendations.
There was a problem hiding this comment.
Fixed — build_recommendations now tracks exactly which case ids a surviving pattern covers and runs the deterministic fallback over whatever's left uncovered, so an omitted or hallucinated-id case no longer silently drops out of the section (commit c301a57).
| for rec in recommendations: | ||
| lines.append(f"### {rec.heading}") | ||
| lines.append("") | ||
| _render_field(lines, "Recommendation", rec.body) |
There was a problem hiding this comment.
A multi-line LLM body is wrapped in a fence and is not escaped. If the body has its own code block, the inner closing ends the outer fence early. The rest of the report then renders incorrectly, and dashboard.parse_report can attach stray bullets to the last scenario entry.
Suggested fix: use a fence that is longer than the longest backtick run in the body (for example ```` or more), or use a ~~~ fence.
There was a problem hiding this comment.
Fixed — the fence is now sized longer than the longest backtick run actually present in the text (per CommonMark), instead of a fixed triple-backtick fence.
| {"timestamp": "2026-09-28T14:23:10+00:00", "suite": "scale_per_decision_prb", "run_type": "regression", "model": "Azure/gpt-5-mini-2025-08-07", "structural_pass_rate": 1.0, "structural_issue_count": 0, "total_tokens": 14435, "mean_wall_clock_seconds": 31.791, "mean_token_coverage": 1.0, "scenarios_scored": 1, "precision": 1.0, "recall": 1.0, "denial_precision": 1.0} | ||
| {"timestamp": "2026-09-28T14:23:10+00:00", "suite": "scale_total_corpus_e2e", "run_type": "regression", "model": "Azure/gpt-5-mini-2025-08-07", "structural_pass_rate": 1.0, "structural_issue_count": 0, "total_tokens": 9231887, "mean_wall_clock_seconds": 118.671, "mean_token_coverage": 1.0, "scenarios_scored": 1, "precision": 1.0, "recall": 0.991, "denial_precision": 1.0} | ||
| {"timestamp": "2026-09-28T14:23:10+00:00", "suite": "scale_total_corpus_prb", "run_type": "regression", "model": "Azure/gpt-5-mini-2025-08-07", "structural_pass_rate": 1.0, "structural_issue_count": 0, "total_tokens": 9109596, "mean_wall_clock_seconds": 291.003, "mean_token_coverage": 1.0, "scenarios_scored": 1, "precision": 1.0, "recall": 0.99, "denial_precision": 1.0} | ||
| {"timestamp": "2026-10-06T11:21:16+00:00", "suite": "correctness_e2e", "run_type": "regression", "model": "Azure/gpt-5-mini-2025-08-07", "scenarios_scored": 8, "precision": 0.969, "recall": 1.0, "denial_precision": 0.75} |
There was a problem hiding this comment.
Not a bug: this PR adds one real correctness_e2e row from 2026-10-06. Is this row intentional? If not, please remove it, because it will show in the dashboard trend.
There was a problem hiding this comment.
Confirmed intentional, not a bug -- kept as-is, no change.
…ection - recommendations.py: with_structured_output can return None instead of raising when the model's response can't be coerced into the schema -- _draft_patterns now checks for that before touching .patterns, instead of letting an AttributeError propagate and skip the fallback entirely. - recommendations.py: build_recommendations now tracks exactly which case ids a surviving LLM pattern actually covers, and runs the deterministic fallback over whatever's left -- a pattern that omits a case, or names it with a hallucinated id, no longer drops it from the section silently (the module's own "never silently blank" guarantee now holds per case, not just when the whole LLM call fails). - conftest.py: _render_field's fixed ``` fence could be closed early by a backtick run already inside the text it wraps (an LLM-drafted recommendation body is the realistic case) -- it now sizes the fence longer than the longest backtick run actually in the text, per CommonMark. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Amitfre15 <amitfrework@gmail.com>
oblinder
left a comment
There was a problem hiding this comment.
Code review: 10 findings, most severe first. Items 1–5 are correctness bugs. Items 6–10 are cleanup. I did not run tests.
| LLM-drafted recommendation body) can never prematurely close the outer fence and corrupt the | ||
| rest of the report.""" | ||
| if "\n" in text: | ||
| fence = "`" * max(_longest_backtick_run(text) + 1, 3) |
There was a problem hiding this comment.
- Bug: the dashboard parser does not read the new fence.
_render_fieldnow writes a fence of 4 or more backticks when the value contains a backtick run. Butparse_reportineval/dashboard/dashboard.py(open check near line 202, close check near line 174) finds a fence only whenline.strip() == "```".
Failure: a Failure/Reason value contains a triple backtick. The parser does not see the 4-backtick fence, so it assigns an empty value, and the drill-down drops the fenced body. Also, when the outer fence has 3 backticks and an inner line is exactly a triple backtick, the parser closes the fence too early.
Fix: make the parser accept a fence of 3 or more backticks, and close the fence only on a line with the same backtick count.
There was a problem hiding this comment.
Fixed -- the fence is sized longer than the longest backtick run in the content (CommonMark), and parse_report now accepts a fence of 3-or-more backticks that closes only on an exact-length match (commit 1a4c37f).
| lines.append("## Improvement recommendations") | ||
| lines.append("") | ||
| for rec in recommendations: | ||
| lines.append(f"### {rec.heading}") |
There was a problem hiding this comment.
- Bug: the heading from the LLM is not escaped.
rec.headinggoes into the Markdown as it is.
Failure: a heading with a newline or a leading -/# breaks the report structure. For example, 'Over-grant\n- **Precision:** 0.1' makes a bullet that dashboard._BULLET_RE gives to the last scenario entry. A heading in backticks (for example `correctness_prb:baseline:over_grant`) matches _ENTRY_RE and makes a false ScenarioEntry.
Fix: replace newlines with spaces and remove (or escape) backticks and leading Markdown characters before you write the heading.
There was a problem hiding this comment.
Fixed -- added _sanitize_recommendation_heading, called at the one ### write site: collapses every whitespace/line-boundary char to a single space and strips backticks before the heading is written (commit 1a4c37f).
| for scenario, props in entries: | ||
| if not props.get("inconsistent"): | ||
| continue | ||
| evidence = f"consistency/{scenario} ({classification}): mismatches -> {props.get('mismatches') or 'none'}" |
There was a problem hiding this comment.
- Bug: raw newlines go into the evidence. The
mismatchesvalue is joined with newlines (test_policy_pipeline_consistency.py:105). This breaks the "one line per case" evidence rule.
Failure: when a scenario has mismatches on 2 or more gates/runs, _case_prompt_line writes the second and later lines without an indent, so they look like new cases in the batched prompt. In the report, '\n'.join(rec.evidence) no longer shows which line belongs to which case.
Fix: join the mismatches with ; (or use _format_pairs), or use !r, as the perturbation path does.
There was a problem hiding this comment.
Fixed -- wrapped mismatches in !r, same convention the perturbation field already uses, so it stays one line (commit 1a4c37f).
| incorrectly_denied = props.get("incorrectly_denied") or {} | ||
| if not (over_grants or under_grants or incorrectly_denied): | ||
| continue | ||
| evidence = ( |
There was a problem hiding this comment.
- Bug: the evidence has no size limit. This builder (and the over/under-grant builders) puts every pair into one evidence string, and all strings go into one batched LLM request.
Failure: scale_total_corpus has a corpus of about 9M tokens. A recall of 0.99 there can mean hundreds or thousands of under-granted pairs. The single _draft_patterns call can then go over the model's context window. call_with_retry fails, and every case falls back to the default grouping, so the LLM analysis is lost for the full run.
Fix: limit the number of pairs in each evidence string (for example, the first N and then ... and K more).
There was a problem hiding this comment.
Fixed -- format_pairs now caps each gate at 20 pairs with a trailing "... and N more" (commit 1a4c37f).
| if inconsistent == 0: | ||
| return "no disagreements" | ||
| fraction = inconsistent / len(entries) | ||
| return "clusters on these scenario(s)" if fraction <= 0.5 else "appears random/widespread" |
There was a problem hiding this comment.
- Bug: the classification is wrong on filtered runs. The fraction counts only the scenarios that ran.
Failure: pytest -m eval -k "consistent and baseline" runs 1 scenario, and that scenario is inconsistent. The fraction is 1/1, so the result is "appears random/widespread". The evidence then tells the LLM to recommend temperature=0, but the data shows only one scenario (the "clusters" case).
Fix: divide by _EXPECTED_CONSISTENCY_SCENARIO_COUNT. Or, when fewer scenarios ran than expected, report that the result is not conclusive.
There was a problem hiding this comment.
Fixed -- classifies against the full expected corpus size (_EXPECTED_CONSISTENCY_SCENARIO_COUNT), not however many scenarios ran this session; a filtered run now reports "not conclusive" instead of forcing a verdict (commit 1a4c37f).
| if not under_grants and not incorrectly_denied: | ||
| continue | ||
| detail_parts = [] | ||
| if under_grants: |
There was a problem hiding this comment.
- Cleanup: pairs appear twice.
under_grant_caseslists bothunder_grantsandincorrectly_denied. The docstring says thatincorrectly_deniedis normally a subset ofunder_grants, so the same pairs appear two times. This doubles the evidence text and the tokens sent to the LLM.
Fix: add only incorrectly_denied - under_grants.
There was a problem hiding this comment.
Fixed -- under_grant_cases now only adds the part of incorrectly_denied not already covered by under_grants (_pairs_not_in), so the same pair is never restated twice (commit 1a4c37f).
| runnable = build_llm(settings).with_structured_output(_PatternBatch) | ||
| messages = _build_draft_messages(cases) | ||
| result = call_with_retry(runnable, messages, settings=settings) | ||
| except Exception as err: |
There was a problem hiding this comment.
- Cleanup: this try/except does no work.
raise_sanitized(err)always raises anLLMErrorsubclass, and the code catches it at once and returns[]. A plainexcept Exception: return []does the same thing. With that change, readers do not have to readraise_sanitizedto see thatresultis always assigned.
There was a problem hiding this comment.
Fixed -- simplified to a plain except Exception: return [] (commit 1a4c37f).
| return "clusters on these scenario(s)" if fraction <= 0.5 else "appears random/widespread" | ||
|
|
||
|
|
||
| def _collect_recommendation_evidence() -> dict: |
There was a problem hiding this comment.
- Cleanup: the same loop exists two times.
_collect_recommendation_evidencerepeats the loop in_write_trend_logover_reports, and the same match of nodeid substrings against the same four marker dicts. If you add a marker or suite to one loop and not the other, it disappears from the trend log or from the recommendations, with no error. The docstring states the duplication but does not remove it.
Fix: put the classification of nodeid to suite/family into one shared helper.
There was a problem hiding this comment.
Fixed -- extracted _classify_nodeid/_NodeidClassification as the one shared nodeid -> suite/family lookup, used by both _write_trend_log and _collect_recommendation_evidence (commit 1a4c37f).
| evidence: list[str] # one line per case this recommendation covers | ||
|
|
||
|
|
||
| def _format_pairs(pairs_by_gate: dict) -> str: |
There was a problem hiding this comment.
- Cleanup: the pair formatter exists two times.
_format_pairsis a copy ofeval.conftest._format_pairs_dict(conftest.py:285) with a different separator. The circular-import reason does not apply, becauseconftestalready imports fromrecommendations.
Fix: keep one function with a sep parameter here, and import it in conftest.
There was a problem hiding this comment.
Fixed -- one shared format_pairs(sep=, limit=) in recommendations.py now; _format_pairs_dict in conftest.py delegates to it instead of keeping its own duplicate copy (commit 1a4c37f).
|
|
||
| return { | ||
| "over_grant": correctness_entries, | ||
| "under_grant": correctness_entries, |
There was a problem hiding this comment.
- Cleanup: the same list is passed two times. The same
correctness_entrieslist goes in asover_grantand asunder_grant, andbuild_recommendationstakes them as two parameters. A caller can pass two different lists by mistake and get over/under findings that do not agree.
Fix: use one correctness= parameter that feeds both builders.
There was a problem hiding this comment.
Fixed -- build_recommendations now takes one correctness= parameter that feeds both over_grant_cases and under_grant_cases, instead of two always-identical over_grant=/under_grant= ones (commit 1a4c37f).
Fixes 10 numbered findings on the recommendations-section code, found during a second human review pass, plus follow-on hardening surfaced by five rounds of /code-review on this diff before pushing. Bugs: - eval/dashboard/dashboard.py (parse_report) + eval/conftest.py (_render_field): a multi-line LLM-drafted body wrapped in a fixed ``` fence could have its own fenced code close the fence early, corrupting the rest of the report. The fence is now sized longer than the longest backtick run in the content (CommonMark), and the parser accepts a fence of 3-or-more backticks that closes only on an exact-length match. - eval/conftest.py: an LLM-drafted recommendation heading is now sanitized (_sanitize_recommendation_heading) before being written to a bare ### line -- an embedded line break or backtick run could otherwise corrupt the report's structure or fabricate a false ScenarioEntry in the dashboard's parser. - eval/recommendations.py (consistency_cases): the already newline-joined `mismatches` string is now wrapped in !r so it stays one line in the batched LLM prompt and the rendered report, matching the `perturbation` field's existing convention. - eval/recommendations.py (format_pairs): pairs are now capped at 20 per gate with a trailing "... and N more", so a large corpus (the Scale suite's total-corpus dimension in particular) can't blow up the single batched _draft_patterns request. - eval/conftest.py (_classify_consistency_disagreements): classifies against the full expected corpus size, not however many scenarios ran this session -- a filtered run no longer misreads "1/1 inconsistent" as "appears random/widespread" when it's the single clustered-scenario case. Gated on != (not <), mirroring _write_trend_log's own == convention. - eval/dashboard/dashboard.py (parse_report): now stops at the "## Improvement recommendations" heading -- its own bullets could otherwise attach to the preceding section's last ScenarioEntry. - eval/recommendations.py (_draft_patterns): the None-result check and `.patterns` access now live inside the same try as the call itself, so a wrong-shaped (not just None) result can't escape as an uncaught exception. Cleanups: - under_grant_cases: incorrectly_denied is only added where it isn't already covered by under_grants (_pairs_not_in), instead of restating the same pairs under both labels. - _draft_patterns: a try/except that round-tripped through raise_sanitized only to immediately catch it as LLMError is now a plain `except Exception: return []`. - One shared format_pairs(sep=, limit=) replaces the duplicate _format_pairs/_format_pairs_dict implementations; eval.conftest imports it. - _classify_nodeid/_NodeidClassification is now the one shared nodeid -> suite/family lookup, used by both _write_trend_log and _collect_recommendation_evidence (previously each repeated the same four-marker-dict match independently). - build_recommendations takes one `correctness=` parameter instead of two always-identical `over_grant=`/`under_grant=` ones. - A pattern's case_ids can no longer produce duplicate evidence lines for a repeated id (dict.fromkeys dedupe). Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Amitfre15 <amitfrework@gmail.com>
Groups the trend-log writer/reader and the dashboard renderer (plus their data/output files) under one package, a sibling of eval/reports/, instead of loose at the eval/ top level. Fixes dashboard.py's reports_dir default (was HERE / "reports", silently wrong once dashboard.py no longer sits beside eval/reports/) and updates every import, path, and doc reference accordingly. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Amitfre15 <amitfrework@gmail.com>
…port (#2472) Implements the "Improvement recommendations" section from the parent epic (#2087), per docs/evaluation/eval-framework.md §9.1 -- surfaces findings in the per-cell eval report as concrete, targeted actions rather than a bare statement of which metrics failed. eval/recommendations.py deterministically gathers evidence for six finding types (over-grant, under-grant, sensitivity, invariance, consistency, Scale correctness mistake) from data the suites already record, each EvidenceCase carrying the concrete scenario(s)/cell(s) that produced it. A category with no supporting evidence in a given run is omitted entirely, never printed as a vacuous pass statement. One batched structured-LLM call clusters same-root-cause cases into distinct, case-specific recommendations instead of one templated blurb per failing scenario, with a deterministic, clearly-labeled fallback grouping when the LLM call fails or returns nothing usable -- the section is never silently blank or fabricated. Also hardened end-to-end: a failure anywhere in the recommendations pipeline (including a settings/client-construction error, not just a transport failure) can never prevent the rest of the per-cell report from being written. under_grant_cases also surfaces a pair that is simultaneously granted AND denied (e.g. a coarse scope split by the auditor into one approved and one best-effort-rejected sub-decision) -- previously such a pair landed in incorrectly_denied without ever reaching under_grants, so the finding never became an EvidenceCase. eval/conftest.py wires this into pytest_sessionfinish via _collect_recommendation_evidence/_render_recommendations_section, reusing the existing nodeid-marker dicts to attribute each already-recorded property dict to its scenario and finding type. docs/evaluation/eval-framework.md §9.1 records the two implementation decisions the code cites by number: no numeric degradation threshold for the Scale row (reuses the suite's own zero-tolerance gate), and the Consistency cluster-vs-random classification's deterministic-fraction heuristic. eval/test_recommendations.py -- 24 new unit tests covering each evidence-gathering function, the LLM-draft/fallback path, and the end-to-end build_recommendations assembly, plus one live-LLM test (require_env_or_skip-gated) confirming the real batched call merges same-root-cause cases. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Amitfre15 <amitfrework@gmail.com>
…ection - recommendations.py: with_structured_output can return None instead of raising when the model's response can't be coerced into the schema -- _draft_patterns now checks for that before touching .patterns, instead of letting an AttributeError propagate and skip the fallback entirely. - recommendations.py: build_recommendations now tracks exactly which case ids a surviving LLM pattern actually covers, and runs the deterministic fallback over whatever's left -- a pattern that omits a case, or names it with a hallucinated id, no longer drops it from the section silently (the module's own "never silently blank" guarantee now holds per case, not just when the whole LLM call fails). - conftest.py: _render_field's fixed ``` fence could be closed early by a backtick run already inside the text it wraps (an LLM-drafted recommendation body is the realistic case) -- it now sizes the fence longer than the longest backtick run actually in the text, per CommonMark. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Amitfre15 <amitfrework@gmail.com>
Fixes 10 numbered findings on the recommendations-section code, found during a second human review pass, plus follow-on hardening surfaced by five rounds of /code-review on this diff before pushing. Bugs: - eval/dashboard/dashboard.py (parse_report) + eval/conftest.py (_render_field): a multi-line LLM-drafted body wrapped in a fixed ``` fence could have its own fenced code close the fence early, corrupting the rest of the report. The fence is now sized longer than the longest backtick run in the content (CommonMark), and the parser accepts a fence of 3-or-more backticks that closes only on an exact-length match. - eval/conftest.py: an LLM-drafted recommendation heading is now sanitized (_sanitize_recommendation_heading) before being written to a bare ### line -- an embedded line break or backtick run could otherwise corrupt the report's structure or fabricate a false ScenarioEntry in the dashboard's parser. - eval/recommendations.py (consistency_cases): the already newline-joined `mismatches` string is now wrapped in !r so it stays one line in the batched LLM prompt and the rendered report, matching the `perturbation` field's existing convention. - eval/recommendations.py (format_pairs): pairs are now capped at 20 per gate with a trailing "... and N more", so a large corpus (the Scale suite's total-corpus dimension in particular) can't blow up the single batched _draft_patterns request. - eval/conftest.py (_classify_consistency_disagreements): classifies against the full expected corpus size, not however many scenarios ran this session -- a filtered run no longer misreads "1/1 inconsistent" as "appears random/widespread" when it's the single clustered-scenario case. Gated on != (not <), mirroring _write_trend_log's own == convention. - eval/dashboard/dashboard.py (parse_report): now stops at the "## Improvement recommendations" heading -- its own bullets could otherwise attach to the preceding section's last ScenarioEntry. - eval/recommendations.py (_draft_patterns): the None-result check and `.patterns` access now live inside the same try as the call itself, so a wrong-shaped (not just None) result can't escape as an uncaught exception. Cleanups: - under_grant_cases: incorrectly_denied is only added where it isn't already covered by under_grants (_pairs_not_in), instead of restating the same pairs under both labels. - _draft_patterns: a try/except that round-tripped through raise_sanitized only to immediately catch it as LLMError is now a plain `except Exception: return []`. - One shared format_pairs(sep=, limit=) replaces the duplicate _format_pairs/_format_pairs_dict implementations; eval.conftest imports it. - _classify_nodeid/_NodeidClassification is now the one shared nodeid -> suite/family lookup, used by both _write_trend_log and _collect_recommendation_evidence (previously each repeated the same four-marker-dict match independently). - build_recommendations takes one `correctness=` parameter instead of two always-identical `over_grant=`/`under_grant=` ones. - A pattern's case_ids can no longer produce duplicate evidence lines for a repeated id (dict.fromkeys dedupe). Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Amitfre15 <amitfrework@gmail.com>
1a4c37f to
e971486
Compare
oblinder
left a comment
There was a problem hiding this comment.
Code review, third pass (head e971486).
All 10 findings from the previous review are fixed, and the offline suite passes (1107 passed). Thank you.
This pass found 10 new findings, but no crash bugs and no regressions. Items 1–4 make the new feature weaker. Items 5–8 are minor. Items 9–10 are design points that can go to a follow-up issue. These findings were not verified one by one, so some can be false positives.
| edit_type = props.get("edit_type", "unknown") | ||
| evidence = ( | ||
| f"{suite}/{scenario} (edit_type={edit_type}): perturbation={props.get('perturbation', '')!r}; " | ||
| f"expected grants -> {format_pairs(props.get('expected_grants') or {})}; " |
There was a problem hiding this comment.
- Worth fixing: the 20-pair limit can hide the real mismatch.
sensitivity_cases(andinvariance_cases, line 249) send the full expected and actual grant lists, and each list is cut at 20 pairs per gate.
Failure: a gate has 30 expected pairs, and the PRB drops pair #25 (sorted order). Both lists then show the same first 20 pairs plus ... and N more. The LLM and the fallback evidence cannot see which pair changed.
Fix: send the difference for each gate (expected - actual and actual - expected), not the full lists.
There was a problem hiding this comment.
Fixed -- sensitivity_cases/invariance_cases now send a diff (_grant_diff: lost/gained per gate) instead of the two full expected/actual lists, so a cap on either list can't hide which pair actually changed (commit 9f009c8).
| # convention sensitivity_cases/invariance_cases use for `perturbation`) keeps this case's | ||
| # evidence a single line, so _case_prompt_line's per-case indentation and | ||
| # ``"\n".join(rec.evidence)`` in the rendered report both still read as one case each. | ||
| evidence = f"consistency/{scenario} ({classification}): mismatches -> {(props.get('mismatches') or 'none')!r}" |
There was a problem hiding this comment.
- Worth fixing: some evidence still has no size limit. The pair limit applies only to pair lists. The
mismatchestext shown with!r, theperturbationpolicy text shown with!r, and the number of cases have no limit, and all of them go into one batched LLM request.
Failure: with a high PRB_CONSISTENCY_REPEATS or a long policy-text edit, the single _draft_patterns request can go over the context window. Then every case falls back, which is the failure the pair limit was added to prevent.
Fix: cut each evidence string at a maximum length (for example, the first N characters and then ... (truncated)).
There was a problem hiding this comment.
Fixed -- a new _truncate(text, limit=500) bounds the mismatches/perturbation evidence fields, applied as repr(_truncate(text)) (not _truncate(repr(text))) so the result stays a well-formed quoted string (commit 9f009c8).
| Recommendation( | ||
| heading=f"{finding_type}: {fallback_key}", | ||
| body=( | ||
| f"LLM pattern analysis unavailable (no usable drafted pattern) -- raw evidence " |
There was a problem hiding this comment.
- Worth fixing: the fallback text is not correct. The fallback body always says "LLM pattern analysis unavailable". Since the last fix, the fallback also runs when the LLM call worked but did not cover some case ids.
Failure: the LLM returns 3 valid patterns but omits 1 case. The report shows 3 LLM recommendations plus one "LLM pattern analysis unavailable" group, so a reader thinks the LLM call failed.
Fix: use a different text for cases that the LLM did not cover (for example, "not covered by the LLM pattern analysis").
There was a problem hiding this comment.
Fixed -- build_recommendations now distinguishes the two cases: "LLM pattern analysis unavailable" only when nothing survived at all, "Not covered by the LLM pattern analysis (other cases in this run were)" when some cases were (commit 9f009c8).
| continue | ||
| evidence = ( | ||
| f"{suite}: over-grants -> {format_pairs(over_grants)}; under-grants -> " | ||
| f"{format_pairs(under_grants)}; incorrectly denied -> {format_pairs(incorrectly_denied)}" |
There was a problem hiding this comment.
- Worth fixing: the same pairs appear twice.
scale_mistake_casesstill writes the fullincorrectly_deniedlist next tounder_grants.under_grant_casesnow removes those pairs with_pairs_not_in, but this builder does not.
Failure: a 100-entity Scale run sends the same pairs twice in the largest evidence strings of the batched request.
Fix: use _pairs_not_in(incorrectly_denied, under_grants) here too.
There was a problem hiding this comment.
Fixed -- scale_mistake_cases now applies pairs_not_in(incorrectly_denied, under_grants) the same way under_grant_cases already does, and omits the clause entirely (not as "-> none") when nothing's left (commit 9f009c8).
| pattern_covered_ids = list(dict.fromkeys(cid for cid in pattern.case_ids if cid in cases_by_id)) | ||
| if not pattern_covered_ids: | ||
| continue | ||
| covered_ids.update(pattern_covered_ids) |
There was a problem hiding this comment.
- Minor: a case can appear under two recommendations. The code removes duplicate case ids inside one pattern, but not across patterns.
Failure: the LLM puts the same case id in two patterns. That case's evidence line then appears under two ### recommendations, which counts the failure two times and goes against the "distinct patterns" rule in the system prompt.
Fix: skip ids that are already in covered_ids.
There was a problem hiding this comment.
Fixed -- a case id already claimed by an earlier pattern is now skipped by every later pattern too (not just deduped within one pattern), so the same case can no longer appear under two different recommendations (commit 9f009c8).
| ) | ||
| if not recommendations: | ||
| return | ||
| lines.append("## Improvement recommendations") |
There was a problem hiding this comment.
- Minor: a failed render can leave a section that is not complete.
_render_recommendations_sectionadds lines directly to the sharedlineslist. If a later step raises, thetry/exceptinpytest_sessionfinishprints "omitting it", but the lines that were already added stay in the report.
Fix: render into a local list, and extend lines only after rendering succeeds.
There was a problem hiding this comment.
Fixed -- _render_recommendations_section now renders into a local list and extends the real report lines only once the whole section succeeds, so a failure partway through can no longer leave a half-written section in the report (commit 9f009c8).
|
|
||
| from eval.trend_log import append_row, pool_consistency_metrics, pool_correctness_metrics, pool_scale_metrics | ||
| from eval.dashboard.trend_log import append_row, pool_consistency_metrics, pool_correctness_metrics, pool_scale_metrics | ||
| from eval.recommendations import build_recommendations, format_pairs |
There was a problem hiding this comment.
- Minor: the LLM client libraries now load in every session. Because of this top-level import, every pytest session that loads
eval/conftest.pyloadslangchain_core,pydantic,langchain_openaiandtenacity. This includes the plain offline unit lane, becauseeval/is intestpaths. This goes against the module's own comment that it "stays free of heavy imports". An import error in that stack would break collection of the whole suite. (Today the cost is small: the offline suite still runs in about 7 seconds.)
Fix: import build_recommendations inside _render_recommendations_section, and keep format_pairs in a small module with no LLM imports.
There was a problem hiding this comment.
Fixed -- format_pairs/pairs_not_in moved to a new eval/pairs.py with no LLM imports, and build_recommendations is now a local import inside _render_recommendations_section instead of a module-top one. This narrows the blast radius of a broken optional LLM dependency to this feature's own test module rather than eval/conftest.py's shared hooks -- it doesn't fully eliminate the import from a bare offline collection, since eval/test_recommendations.py itself still needs it to test _draft_patterns directly; documented that more precisely in both docstrings (commit 9f009c8).
| return [] | ||
|
|
||
| cases_by_id = {case.case_id: case for case in cases} | ||
| patterns = _draft_patterns(cases) |
There was a problem hiding this comment.
- Minor: every eval run with failures calls the LLM, with no way to turn it off.
pytest_sessionfinishmakes a blocking LLM call (with retries) when at least one case fails. This includes-kdebug runs.
Failure: a developer reruns one failing scenario. The end of the run waits up to max_retries × request_timeout plus backoff, and uses tokens.
Fix: control this with an env flag, or skip it for partial runs (the trend log already tags these as "partial").
There was a problem hiding this comment.
Fixed -- a new EVAL_RECOMMENDATIONS_LLM env var (default enabled, documented in docs/evaluation/README.md) lets _draft_patterns return [] immediately with no call attempted at all when set to 0/false/no (commit 9f009c8).
| return nodeid.split("::", 1)[0].startswith(_EVAL_SUITE_PATH_PREFIX) | ||
|
|
||
|
|
||
| def _scenario_name_from_nodeid(nodeid: str) -> str: |
There was a problem hiding this comment.
- Design: two parsers for the same nodeid format.
_scenario_name_from_nodeidusesrindex('['), buteval/dashboard/dashboard.pyuses_SCENARIO_RE(a regex that does not accept nested brackets) for the same job. For unusual param ids they give different results, so the recommendations and the dashboard can show the same test under different scenarios.
Fix: use one shared helper in both places.
There was a problem hiding this comment.
Fixed -- extracted a shared eval/nodeid.py (scenario_for_nodeid), used by both eval.conftest and eval.dashboard.dashboard instead of two independent parsers (commit 9f009c8).
| i += 1 | ||
| continue | ||
|
|
||
| if line == _RECOMMENDATIONS_HEADING: |
There was a problem hiding this comment.
- Design (follow-up): the fix is at the wrong level. The parser stops at the recommendations heading, and
_sanitize_recommendation_headingremoves backticks. Both are there only to keep the line-regex Markdown parser from reading LLM text as scenario data. Any section that is later added after the recommendations will be dropped with no warning, and each new free-form section needs another special case.
Suggestion for a follow-up issue: give the dashboard structured data (for example, a JSON file next to the report) and do not parse Markdown that contains LLM output.
There was a problem hiding this comment.
Noted for a follow-up issue, as suggested -- not addressed in this PR.
Fixes 10 numbered findings from a human reviewer's third pass on the
recommendations section, plus follow-on issues found by two subsequent
/code-review passes.
Weakened-feature bugs (items 1-4):
- sensitivity_cases/invariance_cases sent the full expected/actual grant
lists, each independently capped at 20 pairs/gate -- if a pair dropped
past that cutoff, both truncated lists could show an identical prefix
with no way to tell what actually changed. A new _grant_diff sends only
the difference (lost/gained per gate) instead.
- mismatches/perturbation evidence fields had no size limit at all, since
neither is a pair list format_pairs's own cap reaches. A new _truncate
bounds both (500 chars, "... (truncated)" marker) -- applied as
repr(_truncate(text)), not _truncate(repr(text)), so an already-repr'd
string isn't cut mid-escape.
- The fallback body always said "LLM pattern analysis unavailable" even
when the LLM succeeded and drafted several good patterns, just didn't
cover every case. build_recommendations now distinguishes "unavailable"
(nothing survived at all) from "not covered by the LLM pattern analysis
(other cases in this run were)".
- scale_mistake_cases still restated the same pairs under both
under_grants and incorrectly_denied after a prior round fixed this exact
double-counting in under_grant_cases but missed this sibling function.
Minor (items 5-8):
- A case id could appear under two different recommendations when two
patterns both named it -- deduping only happened within one pattern, not
across them. Now a case claimed by an earlier pattern is skipped by
every later one too.
- _render_recommendations_section appended lines directly to the shared
report-lines list; a build that raised partway through left a
half-finished section in the report instead of a cleanly omitted one. It
now renders into a local list, extending the real one only on success.
- eval/conftest.py imported build_recommendations/format_pairs from
eval.recommendations at module top, which has its own top-level
langchain_core/pydantic/aiac.agent.llm imports -- loaded for every
pytest session touching eval/ (eval/ is in testpaths), with a broken
optional dependency breaking collection of hooks every eval suite
shares. format_pairs/pairs_not_in moved to a new eval/pairs.py with no
heavy imports; build_recommendations is now a local import inside the
function that needs it.
- pytest_sessionfinish made a blocking, retried LLM call whenever any case
failed, with no way to skip it for a quick -k debug rerun. A new
EVAL_RECOMMENDATIONS_LLM env var (default enabled, documented in
docs/evaluation/README.md) lets _draft_patterns return [] immediately
with no attempt made at all.
Design (item 9):
- eval.conftest's rindex("[")-based scenario-name parser and
eval.dashboard.dashboard's regex-based one were independent
implementations that could disagree on an unusual parametrize id. Both
now delegate to one eval/nodeid.py.
(Item 10 -- restructuring the dashboard to consume structured data instead
of parsing Markdown -- is a design point the reviewer explicitly deferred
to a follow-up issue, not addressed here.)
eval/test_pairs.py and eval/test_nodeid.py cover the two new modules
directly, mirroring this repo's one-test-module-per-source-module
convention for eval/'s pure-logic helpers.
Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Amitfre15 <amitfrework@gmail.com>
oblinder
left a comment
There was a problem hiding this comment.
Code review by Claude Code (high effort, 8 angles). This review found 10 possible problems. They were not verified separately, so please treat them as candidates. Inline comments are in severity order:
- Scorer does not apply deny-overrides (
correctness_scorer.py:104), attached atrecommendations.py:188 - Silent LLM failures in
_draft_patterns SCALE_CONCURRENCYREADME regression- Trend-log row from a branch run labelled
regression - Case IDs sent as reprs, so returned IDs do not match
- Wrong "not conclusive" label after one scenario error
- No tests for the conftest glue
8–10. Minor: duplicate guards, duplicate loop,repr()applied twice
🤖 Generated with Claude Code
| cases = [] | ||
| for suite, scenario, props in entries: | ||
| under_grants = props.get("under_grants") or {} | ||
| incorrectly_denied_only = pairs_not_in(props.get("incorrectly_denied") or {}, under_grants) |
There was a problem hiding this comment.
Scorer bug: deny-overrides is not applied (most severe). This line works around the problem, but the cause is in eval/correctness_scorer.py:104 (not in this diff): under_grants = frozenset(expected - granted) does not apply deny-overrides.
If a pair is expected, granted and denied, it counts as a true positive. Recall stays at 1.0 and the test passes, but OPA denies that pair at runtime. The docstring claim "incorrectly_denied is always a subset of under_grants" is not true for this case.
Example: the auditor splits a coarse scope into one approved and one best-effort-rejected sub-decision for the same expected (role, scope) pair. OPA denies the pair, but score_scenario gives recall=1.0, and the trend log records perfect recall. Only this recommendations section shows the miss.
Suggested fix: correct it in the scorer, for example under_grants = expected - (granted - denied). Then remove the special case here.
| if result is None: | ||
| return [] | ||
| return result.patterns | ||
| except Exception: |
There was a problem hiding this comment.
LLM failures are silent. except Exception: return [] logs nothing. A failed LLM call, a schema rejection, or a code bug inside the try all look the same as the EVAL_RECOMMENDATIONS_LLM=0 opt-out.
Example: LLM_MODEL points to a model that does not support structured output. Each eval run then shows "LLM pattern analysis unavailable (no usable drafted pattern)" and gives no cause. The offline tests mock this seam, so they cannot find it.
Suggested fix: at minimum, print or log err before return [].
| | `PRB_CONSISTENCY_REPEATS` (optional) | `eval` | Repeats per scenario; default 5, must be ≥ 2. | | ||
| | `SCALE_TOTAL_CORPUS_SIZE` / `SCALE_TOTAL_CORPUS_ROLES` / `SCALE_PER_DECISION_CANDIDATES` / `SCALE_SEED` (optional) | `eval` (`test_policy_pipeline_scale.py`) | Corpus-size overrides; defaults 100/10/100/0. Override to a small size while iterating — see [policy-eval-scale.md](policy-eval-scale.md). | | ||
| | `SCALE_CONCURRENCY` (optional) | `eval` (`test_policy_pipeline_scale.py`) | Max concurrent PRB decision calls, total-corpus only -- the e2e fixtures' Keycloak-admin calls run one at a time regardless; default 20. | | ||
| | `SCALE_CONCURRENCY` (optional) | `eval` (`test_policy_pipeline_scale.py`) | Max concurrent PRB/Keycloak-admin calls; default 20. | |
There was a problem hiding this comment.
Doc regression. Commit 4fad6fa put back the older, wrong SCALE_CONCURRENCY text. On main this row says that the variable controls only the PRB decision calls (total-corpus) and that the e2e Keycloak-admin calls run one at a time. eval/scale_prb.py agrees: SCALE_CONCURRENCY limits only the PRB ThreadPoolExecutor.
Possible result: a developer increases SCALE_CONCURRENCY to make e2e Keycloak provisioning faster, but it stays serial. Please restore the main text.
| {"timestamp": "2026-09-28T14:23:10+00:00", "suite": "scale_per_decision_prb", "run_type": "regression", "model": "Azure/gpt-5-mini-2025-08-07", "structural_pass_rate": 1.0, "structural_issue_count": 0, "total_tokens": 14435, "mean_wall_clock_seconds": 31.791, "mean_token_coverage": 1.0, "scenarios_scored": 1, "precision": 1.0, "recall": 1.0, "denial_precision": 1.0} | ||
| {"timestamp": "2026-09-28T14:23:10+00:00", "suite": "scale_total_corpus_e2e", "run_type": "regression", "model": "Azure/gpt-5-mini-2025-08-07", "structural_pass_rate": 1.0, "structural_issue_count": 0, "total_tokens": 9231887, "mean_wall_clock_seconds": 118.671, "mean_token_coverage": 1.0, "scenarios_scored": 1, "precision": 1.0, "recall": 0.991, "denial_precision": 1.0} | ||
| {"timestamp": "2026-09-28T14:23:10+00:00", "suite": "scale_total_corpus_prb", "run_type": "regression", "model": "Azure/gpt-5-mini-2025-08-07", "structural_pass_rate": 1.0, "structural_issue_count": 0, "total_tokens": 9109596, "mean_wall_clock_seconds": 291.003, "mean_token_coverage": 1.0, "scenarios_scored": 1, "precision": 1.0, "recall": 0.99, "denial_precision": 1.0} | ||
| {"timestamp": "2026-10-06T11:21:16+00:00", "suite": "correctness_e2e", "run_type": "regression", "model": "Azure/gpt-5-mini-2025-08-07", "scenarios_scored": 8, "precision": 0.969, "recall": 1.0, "denial_precision": 0.75} |
There was a problem hiding this comment.
Trend-log row from a branch run. This row (2026-10-06, correctness_e2e, precision 0.969, denial_precision 0.75) comes from a verification run on this feature branch, but it has run_type: "regression".
The trend log is append-only, and the dashboard charts it as history. After merge, everybody will see this dev run as a real regression data point in the correctness_e2e chart. Please remove this row from the PR, or label it as a non-regression run.
|
|
||
| def _case_prompt_line(case: EvidenceCase) -> str: | ||
| return ( | ||
| f"- case_id={case.case_id!r} finding_type={case.finding_type!r}\n" |
There was a problem hiding this comment.
Case IDs are sent as reprs. {case.case_id!r} sends case_id='correctness_prb:baseline:over_grant' with the quotes. But build_recommendations compares the returned IDs to the bare IDs with an exact match (cid in cases_by_id).
If the model copies the IDs "verbatim, from the input" with the quotes, no ID matches. The code drops all patterns, and the section shows "LLM pattern analysis unavailable" even though the LLM call worked.
Suggested fix: send bare IDs, or remove the quotes before the comparison.
| inconsistent = sum(1 for _scenario, props in entries if props.get("inconsistent")) | ||
| if inconsistent == 0: | ||
| return "no disagreements" | ||
| if len(entries) != _EXPECTED_CONSISTENCY_SCENARIO_COUNT: |
There was a problem hiding this comment.
Wrong "not conclusive" label after one scenario error. entries contains only the scenarios that recorded a result. If one scenario fails before record_property (for example, orchestrate_prb raises), then len(entries) is 7, not 8.
Example: in a full 8-scenario run, one scenario errors and 5 of the other 7 disagree. The label is "not conclusive (7/8 ...)" and not "appears random/widespread", so the LLM gets no cluster-vs-random guidance for the run that needs it most.
Suggested fix: count errored scenarios as scored for this check, or compute the fraction over len(entries) when most scenarios are present.
| return "clusters on these scenario(s)" if fraction <= 0.5 else "appears random/widespread" | ||
|
|
||
|
|
||
| def _collect_recommendation_evidence() -> dict: |
There was a problem hiding this comment.
No tests for the conftest glue. The 23 new tests cover only the pure builders, and they call build_recommendations with hand-built input tuples. No test covers _collect_recommendation_evidence or _render_recommendations_section.
Example: someone renames a record_property key in a suite (such as sensitive), or changes the correctness gate to check only over_grants. A full finding category then disappears from the section, and no test fails. The try/except in pytest_sessionfinish also prints the error and continues, so a broken wiring shows only in a live report.
Suggested fix: add a test that fills _reports with fake reports that have user_properties, and assert on the rendered section.
| ``\\n``. A heading wrapped in backticks (e.g. a quoted nodeid) would make the WHOLE | ||
| ``### `...``` line match that parser's own scenario-entry pattern, fabricating a | ||
| ``ScenarioEntry`` that was never a real test case.""" | ||
| return " ".join(heading.split()).replace("`", "") |
There was a problem hiding this comment.
Minor: two guards for one problem. This function removes all backticks from LLM headings, so the dashboard does not parse them as fake ### ...`` scenario entries. But parse_report now stops at `## Improvement recommendations`, so it never reads these headings.
Result: a correct heading such as Over-interpreting `only` shows as "Over-interpreting only" and loses its formatting. Suggestion: keep the parser stop and remove this sanitizer (or keep only whitespace normalization).
| consistency_entries: list[tuple[str, dict]] = [] | ||
| scale_entries: list[tuple[str, dict]] = [] | ||
|
|
||
| for nodeid, report in _reports.items(): |
There was a problem hiding this comment.
Minor: duplicate loop over _reports. This loop repeats _classify_nodeid and dict(report.user_properties) for each report that _write_trend_log (line ~845) processes in the same pytest_sessionfinish.
The docstring says this is deliberate, to keep the trend-log writer unchanged. But now each new suite or property gate must be added in two places to keep the trend log and the recommendations in step. _classify_nodeid was added to prevent this type of divergence. Suggestion (optional, can be a follow-up): use one pass that classifies each report once and gives the result to both writers.
| if props.get("sensitive", True): | ||
| continue | ||
| edit_type = props.get("edit_type", "unknown") | ||
| perturbation = repr(_truncate(str(props.get("perturbation", "")))) |
There was a problem hiding this comment.
Minor: repr() applied twice. For the mechanical tier, the perturbation string already contains reprs (f"{edit_type}: {find!r} -> {replace!r}"). This line applies repr() again (the same pattern is used in invariance_cases).
Result: the evidence shows perturbation="negation: 'may' -> 'may not'", with backslash-escaped quotes if the text contains quotes. The LLM prompt and the report then show nested, escaped quotes. Suggestion: escape only the newlines (for example .replace("\n", "\\n")) and do not use a second repr().
Summary
Implements the "Improvement recommendations" section from the parent epic (#2087), per
docs/evaluation/eval-framework.md§9.1 — surfaces findings in the per-cell eval report asconcrete, targeted actions rather than a bare statement of which metrics failed.
eval/recommendations.py— deterministically gathers evidence for six finding types(elevated over-grant, elevated under-grant, sensitivity-family failure, invariance-family
failure, consistency disagreement, Scale correctness mistake) from data the suites already
record, each
EvidenceCasecarrying the concrete scenario(s)/cell(s) that produced it. Acategory with no supporting evidence in a given run is omitted entirely, never printed as a
vacuous pass statement.
_draft_patterns) clusters same-root-cause cases intodistinct, case-specific recommendations instead of one templated blurb per failing scenario,
with a deterministic, clearly-labeled fallback grouping (
_fallback_recommendations) when theLLM call fails or returns nothing usable — the section is never silently blank or fabricated.
under_grant_casesalso surfaces a pair that is simultaneously granted AND denied (e.g. acoarse scope split by the auditor into one approved and one best-effort-rejected
sub-decision) — previously such a pair landed in
incorrectly_deniedwithout ever reachingunder_grants, so the finding never became anEvidenceCase.eval/dashboard/— the trend-log writer/reader and the dashboard renderer (plus theirdata/output files) moved under one package, a sibling of
eval/reports/, instead of loose atthe
eval/top level; fixesdashboard.py'sreports_dirdefault, which was silently wrongonce the module no longer sat beside
eval/reports/.eval/test_recommendations.py— 23 new unit tests covering each evidence-gatheringfunction, the LLM-draft/fallback path, and the end-to-end
build_recommendationsassembly.Acceptance criteria (from #2472)
(Correctness over/under-grant, Robustness sensitivity/invariance, Consistency disagreement,
Scale correctness degradation).
vacuous pass statement.
Test plan
.venv/bin/pytest eval/test_recommendations.py -v— 23 new unit tests pass.venv/bin/pytest— full default offline suite, no regressionspair case, confirming the finding now reaches the recommendations section
Out of scope
Closes rossoctl/rossoctl#2472
Assisted-By: Claude (Anthropic AI) noreply@anthropic.com