Skip to content

Flag stale narratives on model-set change; mark section pages as sections#76

Merged
johnjosephhorton merged 1 commit into
mainfrom
report-staleness-and-section-banner
Jul 9, 2026
Merged

Flag stale narratives on model-set change; mark section pages as sections#76
johnjosephhorton merged 1 commit into
mainfrom
report-staleness-and-section-banner

Conversation

@johnjosephhorton

Copy link
Copy Markdown
Contributor

Follow-through on the "is report.html vulnerable to revisions?" discussion.

1. Stale-narrative detection (the real fragility)

The cached executive narrative (LLM prose) is matched to a rebuild on jobs + questions but not on the models inside those jobs. So adding a model to an existing job left the prose describing one model while the tables showed two — silently.

Now:

  • the generation context records the twin model set the narrative was written against (baseline excluded, so it compares twin-to-twin);
  • on rebuild, the report compares it to the current model set and, when they differ, renders a loud "⚠ this written summary is out of date — regenerate it" banner in the Decision section instead of showing stale prose;
  • narratives generated before fingerprinting record nothing and are never falsely flagged (back-compatible).

2. "Not meant to be read separately" banner

The intermediate page files (executive-summary, twin-validation, survey-profile, one-shot-marginals) still exist on disk as the fold-in sources for the consolidated report. Each now carries a "this is one section of a larger report — open the full report" banner. It's inserted outside <main>, so the composer (which lifts only <main>) never pulls it into the report itself — verified by test.

Testing

  • pytest — 261 passed (adds: stale banner shows on model-set change / absent for a fresh narrative; section banner is added outside <main>, is idempotent, and is excluded from the composed report).
  • ruff clean.

Follow-up (noted, not done)

Cache the deterministic executive-summary artifacts (permutation/bootstrap) by the same fingerprint so an unchanged rebuild skips the recompute — the remaining "cheap rewrite" win. Right now the compose is already instant; this would remove the permutation cost on no-op rebuilds.

🤖 Generated with Claude Code

…ions

Two revision-safety fixes for the consolidated report.

Stale-narrative detection. The cached executive narrative (LLM prose) is matched
to a rebuild on jobs + questions but NOT on the models inside those jobs, so
adding a model to an existing job left the prose describing one model while the
tables showed two — silently. The generation context now records the twin model
set the narrative was written against (baseline excluded, twin-to-twin); on
rebuild the report compares it to the current model set and, when they differ,
renders a loud "this written summary is out of date — regenerate it" banner in
the Decision section instead of showing stale prose. Narratives generated before
fingerprinting record nothing and are never falsely flagged.

Section-page banner. The intermediate page files (executive-summary,
twin-validation, survey-profile, one-shot-marginals) still exist on disk as
fold-in sources. Each now gets a "this is one section of a larger report — open
the full report" banner, inserted outside <main> so the consolidated composer
(which lifts only <main>) never pulls it into the report itself.

Follow-up (noted): cache the deterministic executive-summary artifacts by the
same fingerprint so unchanged rebuilds skip the permutation/bootstrap recompute.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@johnjosephhorton johnjosephhorton merged commit db2ccc8 into main Jul 9, 2026
0 of 3 checks passed
@johnjosephhorton johnjosephhorton deleted the report-staleness-and-section-banner branch July 9, 2026 14:35
@greptile-apps

greptile-apps Bot commented Jul 9, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds two defences against a silent staleness problem in the consolidated report: a model-set fingerprint stored at narrative-generation time lets rebuilds detect when cached LLM prose describes a different set of twin models than the current tables, surfacing a loud banner instead of stale text; and a "this is a section" banner is stamped onto each intermediate page file so they are not mistaken for the full report when opened in isolation.

  • Stale narrative detection (generated_reports.py, report_bundle.py): model_labels is written into the generation filters context; on rebuild the bundle compares it against the live twin rows and sets stale=True on generation when they differ. Old narratives without the fingerprint are correctly skipped (narrative_models is not None guard preserves back-compat).
  • Section banner (consolidated_report.py): mark_intermediate_page_html inserts a styled <div> immediately after <body>, outside <main>, so render_consolidated_report (which lifts only <main>) never pulls the banner into the composed report. The function is idempotent and all three properties are verified by test.
  • Testing (test_consolidated_report.py, test_report_conditional_baseline.py): new tests cover banner placement, idempotency, report-exclusion, and the HTML rendering of the stale warning — though the detection logic that sets stale=True in build_report_bundle is not directly exercised by any unit test.

Confidence Score: 4/5

Safe to merge; all changes are additive guardrails with no modifications to existing data paths or schemas.

The staleness detection and section-banner logic are well-scoped and independently verified by tests. The notable gap is that the detection block in build_report_bundle is not exercised by any unit test — only the downstream HTML rendering of a pre-set stale flag is tested. There is also a structural issue where the stale banner is silently absent when generated_markdown is falsy even if stale=True is set, though in practice the two values always arrive together.

The staleness detection block in zwill/report_bundle.py (lines 1053-1070) and the if generated_markdown: branch in zwill/executive_summary.py (lines 452-463) are worth a second look.

Important Files Changed

Filename Overview
zwill/consolidated_report.py Adds mark_intermediate_page_html to insert a section banner outside <main>; idempotency and composed-report exclusion both verified by test.
zwill/executive_summary.py Adds stale_banner rendered only inside the if generated_markdown: branch; silently dropped if generated_markdown is falsy while stale=True.
zwill/generated_reports.py Adds model_labels fingerprint to filters in the generation context; baseline rows correctly excluded.
zwill/report_bundle.py Adds staleness-detection block and section-marking loop; detection block lacks a dedicated unit test.
tests/test_report_conditional_baseline.py New tests cover rendering side only; the detection logic in build_report_bundle is not exercised.
tests/test_consolidated_report.py New test verifies banner placement outside <main>, idempotency, and exclusion from the composed report.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[build_report_bundle] --> B[find_imported_executive_summary_report]
    B --> C{generated_executive found?}
    C -- No --> D[generated_markdown = None]
    C -- Yes --> E[narrative_models = generation.narrative_model_labels]
    E --> F{narrative_models is not None?}
    F -- No old narrative --> G[No stale flag back-compat]
    F -- Yes --> H{sorted narrative_models != current_models?}
    H -- Same --> I[No stale flag]
    H -- Different --> J[generation.stale = True]
    J --> K[build_executive_summary with stale generation]
    G --> K
    I --> K
    D --> K
    K --> L{generated_markdown truthful?}
    L -- Yes --> M[executive_body = stale_banner + callout div]
    L -- No --> N[Fallback body — stale_banner NOT shown]
    M --> O[Write executive-summary.html]
    N --> O
    O --> P[build_consolidated_report_html lifts only main]
    P --> Q[Write report.html / index.html]
    Q --> R[mark_intermediate_page_html on section files]
    R --> S[Banner outside main — excluded from consolidated report]
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A[build_report_bundle] --> B[find_imported_executive_summary_report]
    B --> C{generated_executive found?}
    C -- No --> D[generated_markdown = None]
    C -- Yes --> E[narrative_models = generation.narrative_model_labels]
    E --> F{narrative_models is not None?}
    F -- No old narrative --> G[No stale flag back-compat]
    F -- Yes --> H{sorted narrative_models != current_models?}
    H -- Same --> I[No stale flag]
    H -- Different --> J[generation.stale = True]
    J --> K[build_executive_summary with stale generation]
    G --> K
    I --> K
    D --> K
    K --> L{generated_markdown truthful?}
    L -- Yes --> M[executive_body = stale_banner + callout div]
    L -- No --> N[Fallback body — stale_banner NOT shown]
    M --> O[Write executive-summary.html]
    N --> O
    O --> P[build_consolidated_report_html lifts only main]
    P --> Q[Write report.html / index.html]
    Q --> R[mark_intermediate_page_html on section files]
    R --> S[Banner outside main — excluded from consolidated report]
Loading

Comments Outside Diff (1)

  1. zwill/executive_summary.py, line 452-463 (link)

    P2 Stale banner silently dropped in the fallback path

    stale_banner is only interpolated in the if generated_markdown: branch. If generation carries stale=True but generated_markdown is falsy (e.g. the markdown file on disk is empty), the stale warning is silently swallowed and the deterministic fallback body is displayed with no indication that the narrative is out of date. In production the two values always arrive together from generated_executive, so this is low probability — but the coupling is invisible and would be easy to violate in a future refactor. Consider raising the stale_banner to the outer scope and including it unconditionally whenever stale_banner is non-empty, regardless of which executive_body branch is taken.

Reviews (1): Last reviewed commit: "Flag stale narratives on model-set chang..." | Re-trigger Greptile

Comment on lines 94 to 131
assert comp["share_twin_better"] == 1.0


def test_stale_narrative_banner_when_model_set_changed(tmp_path) -> None:
twin = [_row("openai:gpt-5.5", "r1", "A", 0.20, 1), _row("openai:gpt-5.5", "r2", "B", 0.25, 1)]
build_executive_summary(
twin,
survey="demo",
path=tmp_path / "exec.html",
markdown_path=None,
simulations=25,
seed=1,
generated_markdown="The GPT-5.5 twin captures individual signal.",
generation={"report_id": "r", "stale": True, "stale_reason": "written for openai:gpt-5.5; current: anthropic:claude-opus-4-8, openai:gpt-5.5"},
)
html = (tmp_path / "exec.html").read_text()
assert "This written summary is out of date" in html
assert "anthropic:claude-opus-4-8" in html


def test_no_stale_banner_for_fresh_narrative(tmp_path) -> None:
twin = [_row("openai:gpt-5.5", "r1", "A", 0.20, 1), _row("openai:gpt-5.5", "r2", "B", 0.25, 1)]
build_executive_summary(
twin,
survey="demo",
path=tmp_path / "exec.html",
markdown_path=None,
simulations=25,
seed=1,
generated_markdown="Fresh narrative.",
generation={"report_id": "r"},
)
assert "This written summary is out of date" not in (tmp_path / "exec.html").read_text()


def test_executive_summary_unchanged_without_baseline(tmp_path) -> None:
twin = [_row("openai:gpt-5.5", "r1", "A", 0.20, 1), _row("openai:gpt-5.5", "r2", "B", 0.25, 1)]
result = build_executive_summary(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Detection logic is not unit-tested, only the rendering side is

Both new tests call build_executive_summary with a hand-crafted generation={"stale": True, …} dict. They confirm the HTML banner appears when told to, but the code in build_report_bundle that computes whether staleness should be set (comparing narrative_model_labels vs the current model set) has no direct test. If, for example, the key name mismatched — narrative_model_labels vs model_labels — or the None-guard accidentally fired on an empty list, the detection would silently skip marking and neither test would catch it.

Comment on lines +44 to 46
return html[: match.end()] + "\n" + banner + html[match.end() :]

_CONSOLIDATED_CSS = """

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Missing blank line between mark_intermediate_page_html and the module-level _CONSOLIDATED_CSS string — PEP 8 recommends two blank lines surrounding top-level definitions.

Suggested change
return html[: match.end()] + "\n" + banner + html[match.end() :]
_CONSOLIDATED_CSS = """
return html[: match.end()] + "\n" + banner + html[match.end() :]
_CONSOLIDATED_CSS = """

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant