From 07e4e3a3696b5b2b8e765ca1cd76295d1adefccb Mon Sep 17 00:00:00 2001 From: davidjiang8888 Date: Sun, 16 Aug 2026 01:02:26 -0400 Subject: [PATCH 01/29] Design evidence one-pager --- .../2026-08-16-evidence-one-pager-design.md | 445 ++++++++++++++++++ 1 file changed, 445 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-16-evidence-one-pager-design.md diff --git a/docs/superpowers/specs/2026-08-16-evidence-one-pager-design.md b/docs/superpowers/specs/2026-08-16-evidence-one-pager-design.md new file mode 100644 index 00000000..aa157160 --- /dev/null +++ b/docs/superpowers/specs/2026-08-16-evidence-one-pager-design.md @@ -0,0 +1,445 @@ +# Evidence One-Pager Design + +**Status:** Approved. The owner approved Approach A: place a compact, +evidence-bound summary at the front of the existing Company Workbench HTML +Research Brief while preserving the current Workbench and complete report. + +**Visual reference:** +`/var/folders/cw/xfqgmp_57rn7nn3fq68z_6280000gn/T/codex-clipboard-80b40520-4c8b-493e-89af-a87e159e329b.png` + +**Visual reference SHA-256:** +`d467ce50f7803b3a269b5cfd748a87c1ce4a269345943ca6993d365056c72d59` + +The reference currently lives in temporary attachment storage. If that exact +file is unavailable at visual closeout, the owner must reattach it and its +SHA-256 must match before same-viewport comparison. The implementation must not +copy the reference into tracked product assets. + +**Implementation base:** `origin/main` at +`9147a47c327774e31e5ad76a370561b572d3ccbd` + +## Objective + +Make the product's evidence-first advantage understandable in the time it +takes to read one compact summary. The existing offline HTML Research Brief +will open with an Evidence One-Pager that compresses the selected company's +answer, scenario assumptions, supported evidence, falsifiers, unanswered +questions, provenance, and next research task. + +This is a presentation and projection change. It does not create another +research engine, report format, route, source, readiness state, calculation, +ledger, or recommendation surface. Missing or unproven evidence remains +visible as withheld. + +## Approved Approach + +The Evidence One-Pager becomes the first section of the existing HTML Research +Brief. The same document continues immediately into the complete current +report. The current in-app Company Brief, explicit module-open gate, HTML +Research Brief expander, download control, MIME type, file-name convention, +offline security policy, and full report remain in place. + +This direction was selected over: + +- replacing the Company Workbench first view, which would risk the approved + four-answer workflow and progressive-disclosure contract; and +- creating a separate one-pager download, which would fragment evidence and + require users to choose between a summary and its supporting report. + +"One-Pager" means one bounded summary section. The implementation must not +clip, truncate, hide, or shrink content to promise exactly one physical sheet. + +## Product Position + +The summary is an auditable research artifact, not an investment pitch. It +must make the following product strengths legible on first read: + +- data readiness before analysis; +- independent available, partial, stale, excluded, not-recorded, and withheld + states; +- explicit source, date, rights, field-scope, model, and input identity; +- scenario assumptions rather than targets; +- explicit process status for reviewer-authored thesis and invalidation rather + than generated conviction; +- unanswered questions and the next evidence task; and +- a direct continuation into the complete evidence report. + +## Information Hierarchy + +The summary uses this fixed semantic and visual order. + +### 1. Snapshot header + +Show: + +- `{TICKER} Evidence One-Pager`; +- review cutoff; +- source-through date; +- saved freshness state; +- portable rights state; +- model version; +- snapshot identity; and +- the visible research-only boundary. + +Use `Saved evidence snapshot`. Never use `Certified`, `engine certified`, +`approved`, or another unscoped quality badge. + +### 2. Company Brief answer strip + +Show exactly four independently stateful answers: + +1. Use now; +2. Still withheld; +3. What changed; and +4. Next research task. + +Use now, Still withheld, and Next research task come from the existing frozen +portable inputs. What changed comes only from the already-computed Workbench +change answer supplied to the portable builder together with the selected +ticker and profile key. The portable renderer must not load, recalculate, rank, +or infer a change. Missing, unsafe, unscoped, or mismatched change input renders +a not-recorded answer. + +### 3. Scenario values under assumptions + +Show the existing Bear, Base, and Bull scenarios only. For each scenario show: + +- exact state; +- scenario value per share only when the existing `per_share_state` permits it; +- revenue growth; +- FCF margin; +- WACC; +- terminal growth; and +- forecast years. + +When value per share is withheld, keep safe assumptions visible and show the +existing blocker. Do not show current price, percentage versus spot, upside, +downside, target, margin of safety, expected return, Blue Sky, probability, or +confidence. + +This slice does not create a stricter share-basis readiness rule. When the +existing `per_share_state` permits display, the one-pager may show the supplied +value and must display the existing `share_basis_state` beside it, including +`unverified`. This is scenario-math disclosure, not a readiness promotion or +valuation conclusion. + +### 4. Research case + +Project only existing portable sections: + +- Decision Lab Plan; +- Decision Lab Evidence; +- Business Trend; and +- Key Drivers. + +These cards are process-status projections from the existing frozen snapshot. +They do not promise the underlying thesis or evidence prose. Do not generate, +rewrite, rank, extract from narrative text, or pad claims. Actual +reviewer-authored prose is out of scope for this slice because the current +portable snapshot does not carry field-complete portable lineage for it. + +### 5. Operating and valuation evidence + +Project only the existing portable Business Trend, Key Drivers, DCF bridge, and +valuation-regime status, answer, safe facts, and blocker fields. Scenario and +bridge numbers already frozen in the current snapshot may be shown under their +existing independent states. Do not extract a quarterly value from narrative +text or imply field-level source linkage that the snapshot does not contain. +A later structured-quarterly slice requires explicit field, period, unit, +definition, source, rights, field-scope, and cutoff bindings. + +Do not introduce a capital-allocation scoreboard, ROIC, acquisition return, +buyback, stock-compensation, dilution, customer, ARPC, attach-rate, product +unit, or service-mix metric in this slice. Those fields need separate structured +and reviewed evidence contracts. + +### 6. What could break the research case + +Show only existing portable Risks and Decision Lab Invalidation process-status +content. Missing invalidation status renders `Not recorded`. Actual +reviewer-authored invalidation prose is out of scope for this slice. The product +must not generate falsifiers from scenario output or narrative context. + +### 7. Questions still requiring evidence + +Show: + +- Decision Lab Review Trigger; +- Evidence Gaps; and +- the authoritative Next research task. + +The next task remains evidence/process work. It must not become a buy, sell, +hold, size, allocation, entry, exit, stop, profit, or transaction instruction. + +### 8. Provenance and full-report handoff + +End with: + +- compact source/readiness status; +- blockers and assumptions; +- model and input identity; +- the research-only boundary; and +- `Continue to the full evidence report below.` + +The complete existing report follows in the same document. The summary must +never imply that it replaces full diligence. + +## Data And Trust Contract + +The existing `CompanyWorkbenchHtmlSnapshot` remains the sole frozen source of +portable report truth. The one-pager may select, order, label, and format values +from that snapshot. It may not read files, call providers, refresh data, +rebuild readiness, append a ledger, recalculate DCF, derive spot comparisons, +or mutate session or repository state. + +The snapshot builder may be extended only to freeze the already-computed What +changed answer. That extension must: + +- accept a supplied mapping plus explicit selected ticker and profile key as + part of `CompanyWorkbenchHtmlInputs`; +- normalize and sanitize its title, body, workflow state, context kind, + `source_backed_eligible`, and source references; +- fail closed on absent, unsafe, unscoped, or mismatched input; +- bind the normalized value into snapshot identity; and +- perform no loading or independent change calculation. + +The portable What changed display state is exact: + +| Context kind | Additional condition | Portable display state | +| --- | --- | --- | +| `none` | None | `not_recorded` | +| `snapshot_only` | Safe scoped input | `partial` | +| `source_backed` | `source_backed_eligible=true` and at least one portable safe source reference | `partial`, with a blocker that portable publication/retrieval dates, rights, field scope, and cutoff proof are not frozen | +| `source_backed` | Eligibility or portable reference incomplete | `partial`, with an incomplete-reference blocker | +| Unknown or unsafe | Any | `withheld` | + +Workflow states `monitor`, `review_now`, and `wait_for_evidence` remain visible +as safe labels; they do not independently promote the evidence state. +No What changed answer may become `available` in this slice. A later wider +contract may permit that only after it freezes source identity, +publication/retrieval timestamps, permitted rights, permitted field scope, and +cutoff validation. + +Each section stays independently gated: + +| Summary block | Required evidence | Failure behavior | +| --- | --- | --- | +| Whole summary | Exact ticker/profile scope, safe cutoff, deterministic snapshot identity | Return explicit summary-unavailable markup; keep the full report available. | +| Freshness | Existing selected saved-recency state | Show saved, stale, or withheld; never relabel as current. | +| What changed | Supplied eligible Workbench change answer | Preserve none, snapshot-only, or source-backed; otherwise not recorded. | +| Scenario value | Existing finite scenario result and permitted `per_share_state` | Reuse the current display gate, disclose `share_basis_state`, and expose blockers. | +| Portable business evidence | Existing frozen section state, answer, safe facts, and blockers | Withhold independently; never extract values from narrative text. | +| Thesis or invalidation | Existing frozen Decision Lab process status | Show status only; never synthesize or expose unbound prose. | +| Numeric evidence | Existing scenario or bridge field and its current independent state | Withhold that number and retain the blocker. | +| Capital allocation | Separately approved structured evidence contract | Omit in this slice. | + +Candidate context, synthetic fixtures, stale observations, one ready lane, or a +valid DCF can never promote another section. + +## Preservation Contract + +The change must preserve all current behavior: + +- exact Research Desk, Discover, Company Workbench, Monitor, Data Health, Proof + History, Public, and Operator routing; +- exact ticker/profile scoping and no inferred ticker; +- the in-app Company Brief with Use now, Still withheld, What changed, and Next + research task; +- one authoritative Data Health handoff; +- the complete research-only stop rule; +- the explicit `Open evidence and analysis modules` gate; +- What Changed, Research Decision Lab, Business Trend, cash-generation preview, + Valuation, Forward View, What Remains Withheld, journal, outcome review, + authoring, scorecard, Research Conclusion, and scenario behavior; +- all session keys, reruns, validate-preview-confirm flows, and no-write + defaults; +- the existing HTML Research Brief expander label, download label, Streamlit + key, MIME type, filename convention, CSP, no-JavaScript policy, deterministic + rendering, and complete report sections; and +- fail-closed behavior for missing, stale, unverified, excluded, unsafe, + mismatched, or rights-blocked evidence. + +No existing function may be removed because it is absent from the visual +reference. The reference image supplies editorial hierarchy only; its data, +claims, scenarios, labels, colors, and implied decisions are not evidence. + +## Explicit Do-Not-Copy Contract + +Do not copy or introduce: + +- `Certified one-pager`, `engine certified`, or a global success badge; +- `Why own it`, `Invest`, `CIO decision`, or ownership language; +- rankings, recommendations, scores, allocations, sizing, entries, exits, + stop-losses, take-profits, or transaction behavior; +- current-price comparisons, target prices, percentage upside/downside, or + expected-return framing; +- Blue Sky when the governed engine supplies Bull; +- Monte Carlo percentiles, model probabilities, or confidence-looking + precision; +- generated strongest claims, generated thesis prose, or generated + falsifiers; +- unsupported company-specific KPIs or capital-allocation metrics; +- hard-coded mixed-language copy; or +- a fixed dense canvas, tiny text, color-only status, or hidden print evidence. + +## Visual Direction + +Borrow the reference's editorial rhythm, not its certainty theater: + +- a restrained dark summary surface with strong typographic hierarchy; +- one bright accent rule, used decoratively rather than as an approval signal; +- high-contrast text and thin separators; +- compact answer and scenario strips; +- large plain-language section headings; +- explicit state text beside every color cue; and +- a visible transition from compact summary to complete evidence. + +Use the established Workbench navy, green, amber, cyan, system-sans, and +editorial-serif tokens. Do not add raster assets, logos, gradients, decorative +icons, or another design system. + +## Responsive, Print, And Accessibility Contract + +- The full document has one H1. The in-app fragment uses one H2 beneath the + Workbench H1. +- Use semantic header, main, section, list, table, caption, aside, and footer + structures where applicable. +- DOM order is header, answers, scenarios, research case, operating evidence, + falsifiers, questions, provenance, then the full report. CSS must not reorder + it. +- At `1440x1024` and `1280x720`, scenarios may use three columns and narrative + sections may use two columns. +- At `390x844`, every summary block becomes one column with no document-level + horizontal overflow or clipped boundary. +- The in-app fragment is verified at 100% and 200% browser zoom at + `1280x720`, plus 100% at `390x844`. +- The standalone document is verified at 100% and 200% browser zoom at + `1440x1024` and 100% at `390x844`; one 400% browser-zoom summary case must + remain readable without lost content. +- "No two-dimensional scrolling" applies to the summary and document shell. + Preserved full-report tables may retain their labelled horizontal-scroll + regions. +- Status is always conveyed by text as well as color. Forced-colors preserves + borders and labels. +- Normal text on the new dark surface must meet at least 4.5:1 contrast; large + text and visible component boundaries must meet at least 3:1. +- The page adds no animation and remains compatible with reduced-motion. +- Existing controls retain at least a 44-by-44-pixel target and visible focus. +- The document declares its primary language. Future multilingual spans require + correct `lang` attributes. +- Print retains provenance, blockers, assumptions, and the research-only + boundary. No `max-height`, clipping, ellipsis, hidden overflow, or font + shrinking may be used to force one physical page. +- Automated checks remain engineering evidence only; they do not establish + screen-reader, human-keyboard, WCAG, or assistive-technology completion. + +## Renderer Architecture + +`src/company_workbench_html.py` remains the sole owner of the frozen snapshot, +safe formatting, CSS, fragment, document, bytes, and download specification. +The renderer gains pure summary helpers scoped to the existing document root. +The existing fragment and downloaded document both place the summary before the +current full-report sections. This deliberately preserves the current in-app +preview rather than replacing it with a summary-only surface. The duplicated +summary/full-report facts are an intentional progressive-disclosure trade-off: +the Streamlit expander remains collapsed by default, and the full content +remains available when opened. + +The summary projector must be total for every valid +`CompanyWorkbenchHtmlSnapshot`. A bounded `TypeError` or `ValueError` raised by +summary formatting produces explicit summary-unavailable markup and must not +prevent `_html_brief_content(...)` from rendering the full report. + +`src/dashboard.py` supplies the already-computed What changed answer when it +builds `CompanyWorkbenchHtmlInputs`. It continues to build one portable +snapshot and one download. No second expander, download button, Streamlit key, +route, or renderer pipeline is introduced. + +Tests may add pure helpers and fixtures, but production code must not add a +second source loader, report schema, calculation engine, provider call, +persistence path, or repository artifact. + +## Test Strategy + +Test-first implementation must prove: + +1. the four-answer portable snapshot binds an explicitly scoped, + already-computed What changed value, preserves eligible safe source + references, and fails closed on missing, unsafe, or mismatched input; +2. complete, partial, stale, and fully withheld snapshots render the fixed + summary order with independent states; +3. every displayed value exactly equals an existing supplied snapshot value, + and no narrative text becomes a new numeric or field-level claim; +4. withheld or unknown values never become zero, positive evidence, or a + global green state; +5. no prohibited certification, recommendation, target, upside, probability, + sizing, or transaction language appears; +6. text, references, paths, secrets, controls, and unsafe markup retain the + existing escaping and portable-action policy; +7. a bounded summary-formatting failure renders an unavailable summary while + the existing full report remains present with unchanged section ordering and + security policy; +8. bytes, filename, MIME type, CSP, print output, and snapshot identity remain + deterministic for identical inputs; +9. the in-app Company Brief, module gate, open-state functions, download + control, and session behavior remain intact; +10. Public and Operator never receive the Personal Research portable brief; +11. browser evidence passes for complete, partial, stale, and withheld states + using the exact in-app and standalone viewport/zoom matrix above, normal + and forced colors, contrast, print, focus, runtime, overflow, console, and + no-network checks; and +12. protected `data/`, `outputs/`, and `docs/assets/` bytes and topology remain + unchanged. + +The existing HTML brief browser gate should be extended rather than replaced. +If a later release promises exactly one printed sheet, that separate phase must +add deterministic content budgets and PDF page-count evidence. + +## Implementation Scope + +Expected production and focused-test paths: + +- `src/company_workbench_html.py`; +- `src/dashboard.py`; +- `tests/test_company_workbench_html.py`; +- `tests/test_dashboard_render_smoke.py`; +- `tests/test_research_mode_dashboard_contract.py`; +- `tests/test_company_workbench_html_browser_gate.py`; and +- browser/accessibility contract files only if their exact current assertions + require the new summary marker. + +Active documentation may be updated only to describe the additive summary and +its limits. No provider, valuation, scenario, journal, readiness, source-rights, +data, output, or asset file is in scope. + +## Explicit Non-Goals + +- no missing-data fabrication or readiness activation; +- no source, provider, refresh, import, apply, materialize, rebuild, or + generated-artifact mutation; +- no new route, page, report type, download, ledger, authoring type, scenario, + or persistence model; +- no replacement of the current Company Workbench first view; +- no guarantee of one physical printed page; +- no Figma dependency or token; +- no deployment, publishing, push, PR, mark-ready, merge, or hosted-product + claim; and +- no human, source-rights, market-fit, accessibility-conformance, or investment + performance claim from local automation. + +## Completion + +The local slice is complete only when: + +- every new behavior has a focused RED before production code; +- focused and affected tests pass on final bytes; +- the extended HTML browser matrix passes across required states, widths, + zooms, print, forced colors, and no-network checks; +- current same-viewport captures are compared against the visual reference and + all P0/P1/P2 design mismatches are either fixed or documented as intentional + product-truth differences; if the temporary reference is unavailable, the + owner reattaches the exact SHA-matching image before this check; +- all existing Workbench functions remain in the correct closed/open state; +- protected artifacts remain byte-for-byte unchanged; +- an independent reviewer finds no Critical or Important issue; and +- the exact changed paths are committed locally without push or merge. From ae6520793f769c738f29284c710fe50cb43c3b1d Mon Sep 17 00:00:00 2001 From: davidjiang8888 Date: Sun, 16 Aug 2026 16:02:20 -0400 Subject: [PATCH 02/29] Plan Evidence One-Pager implementation --- .../plans/2026-08-16-evidence-one-pager.md | 2089 +++++++++++++++++ 1 file changed, 2089 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-16-evidence-one-pager.md diff --git a/docs/superpowers/plans/2026-08-16-evidence-one-pager.md b/docs/superpowers/plans/2026-08-16-evidence-one-pager.md new file mode 100644 index 00000000..3f21f168 --- /dev/null +++ b/docs/superpowers/plans/2026-08-16-evidence-one-pager.md @@ -0,0 +1,2089 @@ +# Evidence One-Pager Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add an auditable Evidence One-Pager to the front of the existing Company Workbench HTML Research Brief without changing routes, calculations, readiness, persistence, or the complete report. + +**Architecture:** Extend the existing pure `CompanyWorkbenchHtmlSnapshot` only with an explicitly scoped, already-computed What changed answer. Add one pure summary projector inside `src/company_workbench_html.py`, compose it before the existing full-report content in both fragment and document renderers, and pass the existing Workbench change object from `src/dashboard.py`. Extend the current offline browser gate rather than creating another report or verification engine. + +**Tech Stack:** Python 3.12, frozen dataclasses, deterministic HTML/CSS, Streamlit, pytest, Playwright with local Chrome, repository fingerprint guards. + +## Global Constraints + +- Work only in `/Users/yjian070/Documents/New project/.worktrees/evidence-one-pager` on `codex/evidence-one-pager`. +- Preserve `data/`, `outputs/`, and `docs/assets/` byte-for-byte and do not stage them. +- Never use `git add -A`; stage only the named files for each task. +- The existing Workbench, module gate, full HTML report, download label, MIME type, filename convention, CSP, session behavior, and every existing research function remain available. +- The one-pager selects and formats already-frozen values only; it performs no file I/O, provider call, refresh, readiness rebuild, calculation, ledger append, or repository write. +- Missing, unsafe, stale, unverified, mismatched, excluded, or rights-blocked evidence remains independently `partial`, `not_recorded`, or `withheld`. +- Do not add `Certified`, ownership language, recommendations, rankings, target prices, spot comparisons, upside/downside, probabilities, confidence-looking precision, sizing, allocation, entry/exit, or transaction language. +- Do not add Blue Sky, capital-allocation metrics, company-specific KPIs, generated claims, generated thesis prose, or generated falsifiers. +- `source_backed` What changed content remains `partial` in this slice because portable publication/retrieval dates, rights, field scope, and cutoff proof are not frozen. +- Reuse the current `per_share_state` display decision and show the existing `share_basis_state`; do not introduce a new readiness rule. +- “One-Pager” means a bounded summary section, not a guaranteed single printed sheet; never clip, truncate, or shrink evidence to force page count. +- Local automation is engineering evidence only, not source-rights, current-market, hosted, human-accessibility, market-fit, or investment-performance proof. + +**Pre-execution prerequisite:** this reviewed plan must itself be committed by +named path on `codex/evidence-one-pager` before Task 0 starts. Task 0 must not +waive or ignore an untracked plan file. + +--- + +### Task 0: Re-establish exact branch and protected-artifact truth + +**Files:** +- Create only temporary manifests under `/tmp`; modify no repository file. + +- [ ] **Step 1: Verify the isolated execution boundary** + +Run: + +```bash +pwd +git status --short --branch +git rev-parse HEAD +git merge-base --is-ancestor 9147a47c327774e31e5ad76a370561b572d3ccbd HEAD +git rev-list --left-right --count origin/main...HEAD +git diff --check +``` + +Expected: exact worktree +`/Users/yjian070/Documents/New project/.worktrees/evidence-one-pager`, branch +`codex/evidence-one-pager`, approved design and implementation-plan commits on +top of the named `origin/main` base, no staged/untracked files, and clean diff +hygiene. Stop if another byte is present; classify it before continuing. + +- [ ] **Step 2: Capture the before-state for every protected path** + +Write a sorted relative-path/type/SHA-256 manifest for every file, directory, +and symlink under `data/`, `outputs/`, and `docs/assets/` to a fresh named +directory under `/tmp`. Record the manifest path and SHA-256 in the execution +log. Do not follow symlinks and do not create any file under those protected +trees. + +- [ ] **Step 3: Record the reference image identity without copying it** + +```bash +shasum -a 256 /var/folders/cw/xfqgmp_57rn7nn3fq68z_6280000gn/T/codex-clipboard-80b40520-4c8b-493e-89af-a87e159e329b.png +``` + +Expected: +`d467ce50f7803b3a269b5cfd748a87c1ce4a269345943ca6993d365056c72d59`. +If missing or different, continue code work but mark visual closeout blocked +until the owner reattaches the exact image. + +--- + +### Task 1: Freeze the scoped What changed answer + +**Files:** +- Modify: `src/company_workbench_html.py:51-176,567-604` +- Modify: `tests/test_company_workbench_html.py:1-230` + +**Interfaces:** +- Consumes: `CompanyWorkbenchHtmlInputs.change_answer`, `.change_ticker`, and `.change_profile_key` supplied by Task 4. +- Produces: a four-item `CompanyWorkbenchHtmlSnapshot.answers` tuple ordered `Use now`, `Still withheld`, `What changed`, `Next research task`; the What changed `HtmlBriefAnswer` carries safe source references and blockers. + +- [ ] **Step 1: Add failing snapshot tests for scoped change states** + +Add a default change mapping to `_inputs(...)` and tests that prove exact scope, order, state, references, and failure behavior: + +```python +def _change(**changes: object) -> dict[str, object]: + row: dict[str, object] = { + "state": "review_now", + "answer": "1 unresolved source-backed change needs review.", + "next_task": "Review the changed filing evidence.", + "source_refs": ("https://sec.example/change",), + "source_backed_eligible": True, + "change_context_kind": "source_backed", + } + row.update(changes) + return row + + +def test_snapshot_freezes_four_scoped_answers_without_promoting_source_backed_change(): + snapshot = build_company_workbench_html_snapshot(_inputs()) + + assert [answer.label for answer in snapshot.answers] == [ + "Use now", + "Still withheld", + "What changed", + "Next research task", + ] + changed = snapshot.answers[2] + assert changed.state == "partial" + assert changed.title == "1 unresolved source-backed change needs review." + assert changed.body == "Review the changed filing evidence." + assert [reference.href for reference in changed.source_refs] == [ + "https://sec.example/change" + ] + assert any("portable publication" in blocker.lower() for blocker in changed.blockers) + + +@pytest.mark.parametrize( + ("kind", "eligible", "expected"), + ( + ("none", False, "not_recorded"), + ("snapshot_only", False, "partial"), + ("source_backed", True, "partial"), + ("unknown", True, "withheld"), + ), +) +def test_snapshot_maps_change_context_without_inheriting_workflow_state(kind, eligible, expected): + inputs = _inputs(change_answer=_change(change_context_kind=kind, source_backed_eligible=eligible)) + assert build_company_workbench_html_snapshot(inputs).answers[2].state == expected + + +@pytest.mark.parametrize("kind", ("none", "unknown")) +def test_snapshot_clears_claim_and_references_for_none_or_unknown_context(kind): + changed = build_company_workbench_html_snapshot( + _inputs(change_answer=_change(change_context_kind=kind)) + ).answers[2] + assert changed.title == "No portable change answer." + assert changed.body == "No scoped saved change answer is available." + assert changed.source_refs == () + assert "unresolved source-backed" not in repr(changed).lower() + + +@pytest.mark.parametrize( + ("ticker", "profile"), + (("AMD", "demo"), ("NVDA", "other"), ("", "demo")), +) +def test_snapshot_rejects_unscoped_or_mismatched_change_answer(ticker, profile): + snapshot = build_company_workbench_html_snapshot( + _inputs(change_ticker=ticker, change_profile_key=profile) + ) + changed = snapshot.answers[2] + assert changed.state == "not_recorded" + assert changed.source_refs == () + assert "changed filing" not in repr(changed).lower() + + +def test_snapshot_sanitizes_change_copy_state_and_references(): + snapshot = build_company_workbench_html_snapshot( + _inputs( + change_answer=_change( + state="invented-state", + next_task="buy this stock now", + source_refs=( + "javascript:alert(1)", + "https://sec.example/change", + ), + ) + ) + ) + changed = snapshot.answers[2] + assert changed.state == "withheld" + assert changed.badges == () + assert " HtmlBriefAnswer: + return HtmlBriefAnswer( + "What changed", + "No portable change answer.", + "No scoped saved change answer is available.", + state, + (), + (), + (blocker,), + ) + + +def _portable_change_text(value: object) -> str: + if not isinstance(value, str): + return "" + raw = value.strip() + if not raw or "<" in raw or ">" in raw: + return "" + safe = safe_html_brief_text(raw) + return "" if not safe or safe == _WITHHELD_ACTION else safe + + +def _benign_incomplete_change_reference(raw: str) -> bool: + if len(raw) > 512: + return False + if re.fullmatch(r"sec:[A-Za-z0-9][A-Za-z0-9._-]{0,127}", raw): + return True + if re.fullmatch( + r"sec-accession:[A-Za-z0-9][A-Za-z0-9._-]{0,127}", raw + ): + return True + source_tokens = tuple(part.strip() for part in raw.split(";")) + if 1 <= len(source_tokens) <= 8 and all( + re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}", part) + for part in source_tokens + ): + return True + try: + parsed = urlsplit(raw) + path_parts = tuple(part for part in parsed.path.split("/") if part) + return bool( + parsed.scheme == "consensus" + and parsed.hostname + and re.fullmatch( + r"[A-Za-z0-9][A-Za-z0-9.-]{0,127}", parsed.hostname + ) + and not parsed.username + and not parsed.password + and parsed.port is None + and not parsed.query + and not parsed.fragment + and path_parts + and all( + part not in {".", ".."} + and re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}", part) + for part in path_parts + ) + ) + except ValueError: + return False + + +def _change_reference_is_unsafe(value: object) -> bool: + if not isinstance(value, str): + return True + raw = value.strip() + if not raw or "<" in raw or ">" in raw or "\\" in raw: + return True + if any(unicodedata.category(char) == "Cc" for char in raw): + return True + if _SECRET_PATTERN.search(raw): + return True + if _benign_incomplete_change_reference(raw): + return False + return not bool( + safe_html_brief_reference( + {"label": "Change source", "href": raw} + ).href + ) + + +def _portable_change_answer( + inputs: CompanyWorkbenchHtmlInputs, + ticker: str, +) -> HtmlBriefAnswer: + scoped = _ticker_matches(inputs.change_ticker, ticker) and _profile_matches( + inputs.change_profile_key, + inputs.profile_context.profile_key, + ) + change = _mapping(inputs.change_answer) + if not scoped or not change: + return _neutral_change_answer( + state="not_recorded", + blocker="Portable change scope is absent or mismatched.", + ) + + raw_context_kind = str(change.get("change_context_kind") or "").strip().lower() + if raw_context_kind == "none": + return _neutral_change_answer( + state="not_recorded", + blocker="No source-backed or snapshot-only change is recorded.", + ) + if raw_context_kind not in {"snapshot_only", "source_backed"}: + return _neutral_change_answer( + state="withheld", + blocker="Portable change context is unsupported.", + ) + + raw_workflow_state = str(change.get("state") or "").strip().lower() + title = _portable_change_text(change.get("answer")) + body = _portable_change_text(change.get("next_task")) + refs: list[HtmlBriefSafeReference] = [] + refs_incomplete = False + raw_refs = change.get("source_refs") + refs_unsafe = raw_refs is not None and not isinstance(raw_refs, (list, tuple)) + for index, raw in enumerate(raw_refs if isinstance(raw_refs, (list, tuple)) else ()): + if _change_reference_is_unsafe(raw): + refs_unsafe = True + continue + safe = safe_html_brief_reference( + {"label": f"Change source {index + 1}", "href": raw} + ) + if safe.href and safe not in refs: + refs.append(safe) + elif not safe.href: + refs_incomplete = True + + content_safe = ( + raw_workflow_state in {"monitor", "review_now", "wait_for_evidence"} + and bool(title) + and bool(body) + and not refs_unsafe + ) + if not content_safe: + return _neutral_change_answer( + state="withheld", + blocker="Portable change content, workflow state, or reference is unsafe.", + ) + + if raw_context_kind == "snapshot_only": + state = "partial" + refs = [] + blockers = ("Change context is snapshot-only.",) + else: + state = "partial" + blockers = ( + "Portable publication and retrieval dates, rights, field scope, and cutoff proof are not frozen." + if ( + change.get("source_backed_eligible") is True + and refs + and not refs_incomplete + ) + else "Portable source-backed change eligibility or reference is incomplete." + ,) + + return HtmlBriefAnswer( + "What changed", + title, + body, + state, + tuple( + safe_html_brief_text(item) + for item in (raw_workflow_state, raw_context_kind) + if safe_html_brief_text(item) + ), + tuple(refs), + blockers, + ) +``` + +Construct `answers` in this exact order: + +```python +answers = ( + HtmlBriefAnswer("Use now", "Use now", selected_use_now, normalize_html_brief_state(selected_state), ()), + HtmlBriefAnswer("Still withheld", "Still withheld", selected_blocked, "withheld", ()), + _portable_change_answer(inputs, ticker), + HtmlBriefAnswer( + "Next research task", + _clean_text(inputs.authoritative_task.get("title"), "Next research task"), + _clean_text(inputs.authoritative_task.get("body"), "No portable task."), + normalize_html_brief_state(inputs.authoritative_task.get("state")), + tuple( + safe_html_brief_text(item) + for item in inputs.authoritative_task.get("badges", ()) + if safe_html_brief_text(item) + ) + if isinstance(inputs.authoritative_task.get("badges"), (list, tuple)) + else (), + ), +) +``` + +- [ ] **Step 4: Run the focused change tests and the complete HTML unit file** + +Run: + +```bash +PYTHONDONTWRITEBYTECODE=1 python3 -m pytest -q -p no:cacheprovider \ + tests/test_company_workbench_html.py +``` + +Expected: all tests pass; existing identity assertions are updated only where the intentional fourth answer changes the frozen payload. + +- [ ] **Step 5: Commit the scoped snapshot change** + +```bash +git add src/company_workbench_html.py tests/test_company_workbench_html.py +git diff --cached --check +git commit -m "Freeze Workbench change answer for portable summary" +``` + +--- + +### Task 2: Render the pure Evidence One-Pager + +**Files:** +- Modify: `src/company_workbench_html.py:612-880` +- Modify: `tests/test_company_workbench_html.py:560-1848` + +**Interfaces:** +- Consumes: `CompanyWorkbenchHtmlSnapshot` from Task 1 and existing `_html_brief_*` safety/formatting helpers. +- Produces: `_html_evidence_one_pager(snapshot, heading_level) -> str` and `_html_evidence_one_pager_or_unavailable(snapshot, heading_level) -> str`. + +- [ ] **Step 1: Add failing renderer tests for order, truth, and prohibited content** + +Add `import html` beside the existing standard-library imports in +`tests/test_company_workbench_html.py`. Add tests that parse the actual rendered +markup rather than matching implementation source: + +```python +def test_evidence_one_pager_renders_fixed_order_from_frozen_snapshot_only(): + snapshot = build_company_workbench_html_snapshot(_inputs()) + rendered = html_brief._html_evidence_one_pager(snapshot, heading_level=2) + + markers = ( + "Saved evidence snapshot", + "Company Brief", + "Scenarios under assumptions", + "Research case", + "Operating and valuation evidence", + "What could break the research case", + "Questions still requiring evidence", + "Provenance and boundaries", + "Continue to the full evidence report below.", + ) + assert all(marker in rendered for marker in markers) + assert [rendered.index(marker) for marker in markers] == sorted( + rendered.index(marker) for marker in markers + ) + assert rendered.count('data-section="evidence-one-pager"') == 1 + assert rendered.count("Use now") >= 1 + assert rendered.count("What changed") >= 1 + + +@pytest.mark.parametrize( + "forbidden", + ( + "Certified", + "Why own it", + "Blue Sky", + "upside", + "target price", + "expected return", + "buy", + "sell", + "position size", + ), +) +def test_evidence_one_pager_never_adds_prohibited_claims(forbidden): + rendered = html.unescape( + html_brief._html_evidence_one_pager( + build_company_workbench_html_snapshot(_inputs()), + heading_level=2, + ) + ) + assert forbidden.lower() not in rendered.lower() + + +def test_evidence_one_pager_keeps_withheld_values_non_numeric(): + snapshot = build_company_workbench_html_snapshot(_inputs()) + sentinels = tuple(101001.1 + index for index in range(12)) + base = next(scenario for scenario in snapshot.scenarios if scenario.name == "Base") + withheld_bridge = replace( + base.bridge, + state="withheld", + enterprise_state="withheld", + equity_state="withheld", + per_share_state="withheld", + explicit_total_state="withheld", + projected_fcfs=(sentinels[0],), + discounted_fcfs=(sentinels[1],), + discounted_explicit_total=sentinels[2], + terminal_value=sentinels[3], + discounted_terminal_value=sentinels[4], + enterprise_value=sentinels[5], + cash=sentinels[6], + debt=sentinels[7], + net_debt=sentinels[8], + equity_value=sentinels[9], + shares_outstanding=sentinels[10], + scenario_value_per_share=sentinels[11], + blockers=("The supplied Base bridge is withheld.",), + ) + withheld = replace( + snapshot, + scenarios=tuple( + replace(scenario, bridge=withheld_bridge) + if scenario.name == "Base" + else scenario + for scenario in snapshot.scenarios + ), + ) + rendered = html_brief._html_evidence_one_pager(withheld, heading_level=2) + assert "Scenario value withheld" in rendered + assert "The supplied Base bridge is withheld." in html.unescape(rendered) + assert not [ + value + for value in sentinels + if html_brief.format_html_brief_number(value) in html.unescape(rendered) + ] + + +def test_evidence_one_pager_preserves_share_basis_state_without_using_it_as_gate(): + snapshot = build_company_workbench_html_snapshot(_inputs()) + scenarios = tuple( + replace( + scenario, + bridge=replace( + scenario.bridge, + per_share_state="available", + share_basis_state="unverified", + scenario_value_per_share=31415.92, + ), + ) + for scenario in snapshot.scenarios + ) + rendered = html.unescape( + html_brief._html_evidence_one_pager( + replace(snapshot, scenarios=scenarios), heading_level=2 + ) + ) + assert html_brief.format_html_brief_number(31415.92) in rendered + assert rendered.count("Share basis state: unverified") >= 3 + + +def test_share_basis_state_cannot_override_withheld_per_share_gate(): + snapshot = build_company_workbench_html_snapshot(_inputs()) + base = next(item for item in snapshot.scenarios if item.name == "Base") + changed = replace( + snapshot, + scenarios=tuple( + replace( + scenario, + bridge=replace( + scenario.bridge, + per_share_state="withheld", + share_basis_state="available", + scenario_value_per_share=27182.81, + ), + ) + if scenario.name == "Base" + else scenario + for scenario in snapshot.scenarios + ), + ) + rendered = html.unescape( + html_brief._html_evidence_one_pager(changed, heading_level=2) + ) + assert html_brief.format_html_brief_number(27182.81) not in rendered + assert "Share basis state: available" in rendered + + +@pytest.mark.parametrize( + ("state", "label"), + ( + ("available", "complete"), + ("partial", "partial"), + ("stale", "stale"), + ("withheld", "withheld"), + ), +) +def test_evidence_one_pager_keeps_independent_state_text_visible(state, label): + snapshot = build_company_workbench_html_snapshot(_inputs()) + changed = replace( + snapshot, + freshness_state=state, + answers=tuple(replace(answer, state=state) for answer in snapshot.answers), + ) + rendered = html.unescape( + html_brief._html_evidence_one_pager(changed, heading_level=2) + ) + assert f'data-state="{state}"' in rendered + assert f"State: {label}" in rendered + + +def test_evidence_one_pager_formats_only_supplied_scenario_values(): + snapshot = build_company_workbench_html_snapshot(_inputs()) + scenarios = tuple( + replace( + scenario, + revenue_growth=0.123, + fcf_margin=0.234, + wacc=0.087, + terminal_growth=0.031, + forecast_years=7, + ) + for scenario in snapshot.scenarios + ) + rendered = html.unescape( + html_brief._html_evidence_one_pager( + replace(snapshot, scenarios=scenarios), + heading_level=2, + ) + ) + for supplied in ("12.3%", "23.4%", "8.7%", "3.1%", "7"): + assert supplied in rendered + + +def test_evidence_one_pager_has_summary_scoped_semantics_and_dom_order(): + rendered = html_brief._html_evidence_one_pager( + build_company_workbench_html_snapshot(_inputs()), + heading_level=2, + ) + parser = _OnePagerHtmlParser() + parser.feed(rendered) + assert parser.tags[0] == "section" + assert {"header", "section", "ol", "table", "caption", "aside"} <= set( + parser.tags + ) + assert not {"main", "footer", "script", "form", "iframe"} & set(parser.tags) + assert parser.headings[0] == ("h2", "NVDA Evidence One-Pager") + assert "Portable evidence provenance" in parser.captions + assert parser.answer_item_count == 4 + assert parser.scenario_item_count == 3 + assert len(parser.state_nodes) == len(parser.state_roles) + assert len(parser.state_roles) == len(set(parser.state_roles)) + assert all(role and state for role, state in parser.state_role_pairs) + assert len(parser.share_basis_pairs) == 4 + markers = ( + 'data-section="one-pager-header"', + 'data-section="one-pager-answers"', + 'data-section="one-pager-scenarios"', + 'data-section="one-pager-research-case"', + 'data-section="one-pager-operating-valuation"', + 'data-section="one-pager-break-case"', + 'data-section="one-pager-questions"', + 'data-section="one-pager-provenance"', + 'data-section="one-pager-handoff"', + ) + assert [rendered.index(marker) for marker in markers] == sorted( + rendered.index(marker) for marker in markers + ) +``` + +Add a small `_OnePagerHtmlParser(HTMLParser)` beside the existing +`_BriefHtmlParser`. It must begin recording only at the outer element with +`data-section="evidence-one-pager"`, track nested depth, and collect tags, +headings, caption text, answer/scenario list-item counts, every +`data-state`/`data-state-role` pair, and every share-basis role/state pair from +that subtree. This prevents the existing full +report landmarks/tables from creating a false green. + +Add an escaping test using a manually replaced answer containing `", + next_task="buy this stock now", + source_refs=( + "javascript:alert(1)", + "https://sec.example/change", + ), + ) + ) + ) + changed = snapshot.answers[2] + assert changed.state == "withheld" + assert changed.badges == () + assert " Date: Sun, 16 Aug 2026 16:24:45 -0400 Subject: [PATCH 04/29] Render evidence one-pager from frozen research truth --- src/company_workbench_html.py | 524 +++++++++++++++++++++++++++ tests/test_company_workbench_html.py | 316 ++++++++++++++++ 2 files changed, 840 insertions(+) diff --git a/src/company_workbench_html.py b/src/company_workbench_html.py index e09a26b9..ad621922 100644 --- a/src/company_workbench_html.py +++ b/src/company_workbench_html.py @@ -887,6 +887,79 @@ def _html_brief_css(root: str) -> str: {root} .srcc-section, {root} .srcc-card {{ break-inside: avoid; }} {root} .srcc-advanced-evidence, {root} .srcc-boundary {{ display: block; }} }} +{root} .srcc-one-pager {{ + color: #f8fafc; + background: #0b1b2b; + border-top: .35rem solid #f59e0b; + padding: 1.25rem; +}} +{root} .srcc-one-pager * {{ color: inherit; }} +{root} .srcc-one-pager-grid {{ + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1px; + background: #64748b; +}} +{root} .srcc-one-pager-card {{ + min-width: 0; + background: #0b1b2b; + padding: 1rem; +}} +{root} .srcc-one-pager-scenarios {{ + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: .75rem; + list-style: none; + margin: 0; + padding: 0; +}} +{root} .srcc-one-pager a {{ color: #67e8f9; }} +{root} .srcc-one-pager .srcc-boundary {{ border-color: #60a5fa; }} +{root} .srcc-one-pager .srcc-state-available {{ border-color: #34d399; }} +{root} .srcc-one-pager .srcc-state-partial {{ border-color: #fbbf24; }} +{root} .srcc-one-pager .srcc-state-withheld {{ border-color: #f87171; }} +{root} .srcc-one-pager .srcc-state-stale {{ border-color: #c4b5fd; }} +{root} .srcc-one-pager .srcc-state-not_recorded {{ border-color: #94a3b8; }} +{root} .srcc-one-pager .srcc-state-excluded {{ border-color: #7dd3fc; }} +{root} .srcc-one-pager .table-scroll {{ overflow: visible; }} +{root} .srcc-one-pager .srcc-table {{ + min-width: 0; + table-layout: fixed; + width: 100%; +}} +{root} .srcc-one-pager .srcc-table th, +{root} .srcc-one-pager .srcc-table td {{ overflow-wrap: anywhere; }} +@media (max-width: 640px) {{ + {root} .srcc-one-pager-grid, + {root} .srcc-one-pager-scenarios {{ grid-template-columns: 1fr; }} +}} +@media (forced-colors: active) {{ + {root} .srcc-one-pager, + {root} .srcc-one-pager-card {{ border: 1px solid CanvasText; }} +}} +@media print {{ + {root} .srcc-one-pager {{ + color: #000 !important; + background: #fff !important; + border-color: #000 !important; + break-inside: auto; + }} + {root} .srcc-one-pager * {{ color: #000 !important; }} + {root} .srcc-one-pager-grid {{ background: #000 !important; }} + {root} .srcc-one-pager-card {{ background: #fff !important; }} + {root} .srcc-one-pager .srcc-state, + {root} .srcc-one-pager .srcc-boundary, + {root} .srcc-one-pager [data-section="one-pager-provenance"], + {root} .srcc-one-pager .srcc-table th, + {root} .srcc-one-pager .srcc-table td, + {root} .srcc-one-pager-card {{ + border-color: #000 !important; + }} + {root} .srcc-one-pager a {{ + color: #000 !important; + text-decoration: underline !important; + }} +}} """.strip() @@ -930,6 +1003,457 @@ def _html_brief_reference_markup(reference: HtmlBriefSafeReference) -> str: return label +def _section_by_key( + sections: tuple[HtmlBriefSection, ...], + key: str, +) -> HtmlBriefSection | None: + return next((section for section in sections if section.key == key), None) + + +def _answer_by_label( + answers: tuple[HtmlBriefAnswer, ...], + label: str, +) -> HtmlBriefAnswer | None: + return next((answer for answer in answers if answer.label == label), None) + + +def _html_one_pager_role(*parts: object) -> str: + tokens = tuple( + token + for part in parts + for token in re.findall(r"[a-z0-9]+", str(part or "").lower()) + ) + return "-".join(tokens)[:120] or "one-pager-unavailable" + + +def _html_one_pager_state_attributes(state: object, role: str) -> str: + normalized, _ = _html_brief_state(state) + return ( + f'data-state="{normalized}" ' + f'data-state-role="{html.escape(role, quote=True)}"' + ) + + +def _html_one_pager_references( + references: tuple[HtmlBriefSafeReference, ...], +) -> str: + if not references: + return "" + items = "".join( + f"
  • {_html_brief_reference_markup(reference)}
  • " + for reference in references + ) + return f"

    Source references

      {items}
    " + + +def _html_one_pager_unavailable_section() -> HtmlBriefSection: + return HtmlBriefSection( + key="unavailable", + title="Not recorded", + state="not_recorded", + answer="No portable evidence is recorded for this section.", + facts=(), + blockers=("The frozen snapshot does not contain this section.",), + ) + + +def _html_one_pager_section_card( + section: HtmlBriefSection | None, + *, + role: str, + heading: str, +) -> str: + selected = section or _html_one_pager_unavailable_section() + facts = "" + if selected.facts: + facts = '
    ' + "".join( + f"
    {_html_brief_text(label)}
    {_html_brief_text(value)}
    " + for label, value in selected.facts + ) + "
    " + return ( + '
    ' + f"<{heading}>{_html_brief_text(selected.title)}" + f"{_html_brief_state_markup(selected.state)}" + f"

    {_html_brief_text(selected.answer, 'No portable evidence recorded.')}

    " + f"{facts}{_html_brief_blockers(selected.blockers)}" + "
    " + ) + + +def _html_one_pager_answer_card( + answer: HtmlBriefAnswer, + *, + role: str, + heading: str, + answer_item: bool = False, + container: str = "li", +) -> str: + item_attribute = ' data-answer-item=""' if answer_item else "" + return ( + f'<{container} class="srcc-one-pager-card srcc-card"' + f'{item_attribute} {_html_one_pager_state_attributes(answer.state, role)}>' + f"<{heading}>{_html_brief_text(answer.label)}" + f"{_html_brief_state_markup(answer.state)}" + f"

    {_html_brief_text(answer.title, 'No portable answer.')}

    " + f"

    {_html_brief_text(answer.body, 'No portable answer.')}

    " + f"{_html_one_pager_references(answer.source_refs)}" + f"{_html_brief_blockers(answer.blockers)}" + f"" + ) + + +def _html_one_pager_available_number( + value: object, + state: object, + *, + currency: str = "", +) -> str | None: + if normalize_html_brief_state(state) != "available": + return None + if isinstance(value, bool) or not isinstance(value, (int, float)): + return None + if not math.isfinite(float(value)): + return None + return format_html_brief_number(value, currency=currency) + + +def _html_one_pager_share_basis( + state: object, + *, + role: str, +) -> str: + supplied = _html_brief_text(state, "not recorded") + return ( + '

    ' + f"Share basis state: {supplied}

    " + ) + + +def _html_evidence_one_pager( + snapshot: CompanyWorkbenchHtmlSnapshot, + heading_level: int, +) -> str: + """Project a summary from an already-frozen snapshot without side effects.""" + level = max(1, min(6, int(heading_level))) + heading = f"h{level}" + section_heading = f"h{min(6, level + 1)}" + card_heading = f"h{min(6, level + 2)}" + + header_state_rows = "".join( + '
    ' + f"

    {_html_brief_text(label)}

    {_html_brief_state_markup(state)}
    " + for field, label, state in ( + ("freshness-state", "Freshness", snapshot.freshness_state), + ("rights-state", "Rights", snapshot.rights_state), + ) + ) + header = ( + '
    ' + '

    Saved evidence snapshot

    ' + f'<{heading} id="evidence-one-pager-title">' + f"{_html_brief_text(snapshot.ticker, 'Research')} Evidence One-Pager" + f"" + '
    ' + f"
    Ticker
    {_html_brief_text(snapshot.ticker)}
    " + f"
    Review cutoff
    {_html_brief_text(snapshot.review_cutoff)}
    " + f"
    Source as of
    {_html_brief_text(snapshot.source_as_of)}
    " + f"
    Model version
    {_html_brief_text(snapshot.model_version)}
    " + f"
    Snapshot identity
    {_html_brief_text(snapshot.identity)}
    " + "
    " + f'
    {header_state_rows}
    ' + f'

    {_html_brief_text(snapshot.boundary)}

    ' + f"{_html_brief_blockers(snapshot.blockers)}" + "
    " + ) + + answer_items = "".join( + _html_one_pager_answer_card( + answer, + role=_html_one_pager_role("answers", answer.label), + heading=card_heading, + answer_item=True, + ) + for answer in snapshot.answers + ) + answers = ( + '
    ' + f"<{section_heading}>Company Brief" + f'
      {answer_items}
    ' + "
    " + ) + + scenario_items = [] + for scenario in snapshot.scenarios: + scenario_role = _html_one_pager_role("scenarios", scenario.name) + value_role = _html_one_pager_role("scenarios", scenario.name, "value-per-share") + value = _html_one_pager_available_number( + scenario.bridge.scenario_value_per_share, + scenario.bridge.per_share_state, + currency=scenario.bridge.currency, + ) + value_copy = f"Scenario value: {value}" if value is not None else "Scenario value withheld" + scenario_items.append( + '
  • ' + f"<{card_heading}>{_html_brief_text(scenario.name)}" + f"{_html_brief_state_markup(scenario.state)}" + f"

    {'Modified Base assumptions' if scenario.modified else 'Recorded assumptions'}

    " + '
    ' + f"
    Method
    {_html_brief_text(scenario.method_name)}
    " + f"
    Revenue growth
    {format_html_brief_number(scenario.revenue_growth, percent=True)}
    " + f"
    FCF margin
    {format_html_brief_number(scenario.fcf_margin, percent=True)}
    " + f"
    WACC
    {format_html_brief_number(scenario.wacc, percent=True)}
    " + f"
    Terminal growth
    {format_html_brief_number(scenario.terminal_growth, percent=True)}
    " + f"
    Forecast years
    {format_html_brief_number(scenario.forecast_years)}
    " + "
    " + '
    ' + f"

    {value_copy}

    {_html_brief_state_markup(scenario.bridge.per_share_state)}
    " + f"{_html_one_pager_share_basis(scenario.bridge.share_basis_state, role=_html_one_pager_role('scenarios', scenario.name, 'share-basis'))}" + f"{_html_brief_blockers(scenario.bridge.blockers)}" + "
  • " + ) + scenarios = ( + '
    ' + f"<{section_heading}>Scenarios under assumptions" + f'
      {"".join(scenario_items)}
    ' + "
    " + ) + + research_case_sources = ( + ("decision", "plan", _section_by_key(snapshot.decision_lanes, "plan")), + ("decision", "evidence", _section_by_key(snapshot.decision_lanes, "evidence")), + ("research", "business-trend", _section_by_key(snapshot.research_sections, "business-trend")), + ("research", "key-drivers", _section_by_key(snapshot.research_sections, "key-drivers")), + ) + research_case_cards = "".join( + _html_one_pager_section_card( + section, + role=_html_one_pager_role("research-case", source, key), + heading=card_heading, + ) + for source, key, section in research_case_sources + ) + research_case = ( + '
    ' + f"<{section_heading}>Research case" + f'
    {research_case_cards}
    ' + "
    " + ) + + operating_sources = tuple( + (key, _section_by_key(snapshot.research_sections, key)) + for key in ("business-trend", "key-drivers", "valuation-regime") + ) + operating_cards = "".join( + _html_one_pager_section_card( + section, + role=_html_one_pager_role("operating-valuation", "research", key), + heading=card_heading, + ) + for key, section in operating_sources + ) + base = next((scenario for scenario in snapshot.scenarios if scenario.name == "Base"), None) + if base is None: + bridge_markup = _html_one_pager_section_card( + None, + role=_html_one_pager_role("operating-valuation", "base-bridge"), + heading=card_heading, + ) + else: + bridge_values = ( + ("discounted-explicit-total", "Discounted explicit total", base.bridge.discounted_explicit_total, base.bridge.explicit_total_state), + ("terminal-value", "Terminal value", base.bridge.terminal_value, base.bridge.enterprise_state), + ("discounted-terminal-value", "Discounted terminal value", base.bridge.discounted_terminal_value, base.bridge.enterprise_state), + ("enterprise-value", "Enterprise value", base.bridge.enterprise_value, base.bridge.enterprise_state), + ("cash", "Cash", base.bridge.cash, base.bridge.equity_state), + ("debt", "Debt", base.bridge.debt, base.bridge.equity_state), + ("net-debt", "Net debt", base.bridge.net_debt, base.bridge.equity_state), + ("equity-value", "Equity value", base.bridge.equity_value, base.bridge.equity_state), + ("supplied-shares", base.bridge.shares_label, base.bridge.shares_outstanding, base.bridge.per_share_state), + ("supplied-value-per-share", "Supplied value per share", base.bridge.scenario_value_per_share, base.bridge.per_share_state), + ) + bridge_rows = "".join( + '' + f'{_html_brief_text(label)}' + f"{_html_one_pager_available_number(value, state, currency=base.bridge.currency) or 'withheld'}" + f"{_html_brief_state_markup(state)}{_html_brief_blockers(base.bridge.blockers)}" + for key, label, value, state in bridge_values + ) + bridge_markup = ( + '
    ' + f"<{card_heading}>Supplied Base bridge values" + '
    ' + "" + "" + f"{bridge_rows}
    Supplied Base bridge values
    FieldRecorded evidence
    " + f"{_html_one_pager_share_basis(base.bridge.share_basis_state, role=_html_one_pager_role('operating-valuation', 'base-bridge', 'share-basis'))}" + "
    " + ) + operating = ( + '
    ' + f"<{section_heading}>Operating and valuation evidence" + f'
    {operating_cards}
    {bridge_markup}' + "
    " + ) + + break_sources = ( + ("research", "risks", _section_by_key(snapshot.research_sections, "risks")), + ("decision", "invalidation", _section_by_key(snapshot.decision_lanes, "invalidation")), + ) + break_cards = "".join( + _html_one_pager_section_card( + section, + role=_html_one_pager_role("break-case", source, key), + heading=card_heading, + ) + for source, key, section in break_sources + ) + break_case = ( + '
    ' + f"<{section_heading}>What could break the research case" + f'
    {break_cards}
    ' + "
    " + ) + + question_sections = ( + ("decision", "review-trigger", _section_by_key(snapshot.decision_lanes, "review-trigger")), + ("research", "evidence-gaps", _section_by_key(snapshot.research_sections, "evidence-gaps")), + ) + question_cards = "".join( + _html_one_pager_section_card( + section, + role=_html_one_pager_role("questions", source, key), + heading=card_heading, + ) + for source, key, section in question_sections + ) + next_task = _answer_by_label(snapshot.answers, "Next research task") + if next_task is None: + next_task = HtmlBriefAnswer( + "Next research task", + "Not recorded", + "No portable evidence is recorded for this section.", + "not_recorded", + (), + (), + ("The frozen snapshot does not contain this section.",), + ) + next_task_card = _html_one_pager_answer_card( + next_task, + role=_html_one_pager_role("questions", "answer", "next-research-task"), + heading=card_heading, + container="div", + ) + questions = ( + '
    ' + f"<{section_heading}>Questions still requiring evidence" + f'
    {question_cards}{next_task_card}
    ' + "
    " + ) + + provenance_top_states = "".join( + '
    ' + f"

    {_html_brief_text(label)}

    {_html_brief_state_markup(state)}
    " + for field, label, state in ( + ("freshness-state", "Freshness", snapshot.freshness_state), + ("rights-state", "Rights", snapshot.rights_state), + ) + ) + provenance_bodies = [] + for ordinal, row in enumerate(snapshot.evidence_rows, start=1): + role = _html_one_pager_role( + "provenance", "row", ordinal, row.section, row.source_id + ) + fields = ( + ("State", _html_brief_state_markup(row.state)), + ("Section", _html_brief_text(row.section)), + ("Source ID", _html_brief_text(row.source_id)), + ("Reference", _html_brief_reference_markup(row.source_ref)), + ("As of", _html_brief_text(row.as_of)), + ("Retrieved", _html_brief_text(row.retrieved_at)), + ("Rights", _html_brief_text(row.rights_state)), + ("Field scope", _html_brief_text(row.field_scope_state)), + ("Model identity", _html_brief_text(row.model_identity)), + ("Input identity", _html_brief_text(row.input_identity)), + ("Blockers", _html_brief_blockers(row.blockers) or "None recorded"), + ) + rows = "".join( + f'{label}{value}' + for label, value in fields + ) + provenance_bodies.append( + f'{rows}' + ) + if not provenance_bodies: + unavailable = _html_one_pager_unavailable_section() + provenance_bodies.append( + '' + 'Evidence state' + f"{_html_brief_state_markup(unavailable.state)}" + f"

    {_html_brief_text(unavailable.answer)}

    " + f"{_html_brief_blockers(unavailable.blockers)}" + ) + provenance = ( + '" + ) + handoff = ( + '
    ' + "

    Continue to the full evidence report below.

    " + "
    " + ) + return ( + '
    ' + f"{header}{answers}{scenarios}{research_case}{operating}{break_case}" + f"{questions}{provenance}{handoff}
    " + ) + + +def _html_evidence_one_pager_or_unavailable( + snapshot: CompanyWorkbenchHtmlSnapshot, + heading_level: int, +) -> str: + """Fail closed to an explicit summary state without affecting the full report.""" + try: + return _html_evidence_one_pager(snapshot, heading_level) + except Exception: + level = max(1, min(6, int(heading_level))) + heading = f"h{level}" + card_heading = f"h{min(6, level + 1)}" + unavailable = _html_one_pager_section_card( + None, + role=_html_one_pager_role("summary", "unavailable"), + heading=card_heading, + ) + return ( + '
    ' + f"<{heading}>Evidence One-Pager unavailable" + f"{unavailable}
    " + ) + + def _html_brief_content(snapshot: CompanyWorkbenchHtmlSnapshot, *, heading_level: int) -> str: heading = f"h{heading_level}" base = next((scenario for scenario in snapshot.scenarios if scenario.name == "Base"), None) diff --git a/tests/test_company_workbench_html.py b/tests/test_company_workbench_html.py index d54a7329..e55b0d03 100644 --- a/tests/test_company_workbench_html.py +++ b/tests/test_company_workbench_html.py @@ -1,4 +1,5 @@ from dataclasses import replace +import html from html.parser import HTMLParser from pathlib import Path import re @@ -1982,6 +1983,321 @@ def handle_data(self, data): self.headings.append((self._heading, data.strip())) +class _OnePagerHtmlParser(HTMLParser): + def __init__(self): + super().__init__() + self.tags = [] + self.headings = [] + self.captions = [] + self.answer_item_count = 0 + self.scenario_item_count = 0 + self.state_nodes = [] + self.state_roles = [] + self.state_role_pairs = [] + self.share_basis_pairs = [] + self.labelled_asides = [] + self._depth = 0 + self._text_tag = None + self._text_parts = [] + + def handle_starttag(self, tag, attrs): + attributes = dict(attrs) + if not self._depth: + if attributes.get("data-section") != "evidence-one-pager": + return + self._depth = 1 + else: + self._depth += 1 + self.tags.append(tag) + if tag in {"h1", "h2", "h3", "h4", "h5", "h6", "caption"}: + self._text_tag = tag + self._text_parts = [] + if attributes.get("data-answer-item") is not None: + self.answer_item_count += 1 + if attributes.get("data-scenario-item") is not None: + self.scenario_item_count += 1 + if "data-state" in attributes or "data-state-role" in attributes: + state = attributes.get("data-state", "") + role = attributes.get("data-state-role", "") + self.state_nodes.append(tag) + self.state_roles.append(role) + self.state_role_pairs.append((role, state)) + if ( + "data-share-basis-role" in attributes + or "data-share-basis-state" in attributes + ): + self.share_basis_pairs.append( + ( + attributes.get("data-share-basis-role", ""), + attributes.get("data-share-basis-state", ""), + ) + ) + if tag == "aside" and attributes.get("aria-labelledby"): + self.labelled_asides.append(attributes["aria-labelledby"]) + + def handle_endtag(self, tag): + if not self._depth: + return + if tag == self._text_tag: + text = "".join(self._text_parts).strip() + if tag == "caption": + self.captions.append(text) + else: + self.headings.append((tag, text)) + self._text_tag = None + self._text_parts = [] + self._depth -= 1 + + def handle_data(self, data): + if self._depth and self._text_tag: + self._text_parts.append(data) + + +def test_evidence_one_pager_renders_fixed_order_from_frozen_snapshot_only(): + snapshot = build_company_workbench_html_snapshot(_inputs()) + rendered = html_brief._html_evidence_one_pager(snapshot, heading_level=2) + + markers = ( + "Saved evidence snapshot", + "Company Brief", + "Scenarios under assumptions", + "Research case", + "Operating and valuation evidence", + "What could break the research case", + "Questions still requiring evidence", + "Provenance and boundaries", + "Continue to the full evidence report below.", + ) + assert all(marker in rendered for marker in markers) + assert [rendered.index(marker) for marker in markers] == sorted( + rendered.index(marker) for marker in markers + ) + assert rendered.count('data-section="evidence-one-pager"') == 1 + assert rendered.count("Use now") >= 1 + assert rendered.count("What changed") >= 1 + + +@pytest.mark.parametrize( + "forbidden", + ( + "Certified", + "Why own it", + "Blue Sky", + "upside", + "target price", + "expected return", + "buy", + "sell", + "position size", + ), +) +def test_evidence_one_pager_never_adds_prohibited_claims(forbidden): + rendered = html.unescape( + html_brief._html_evidence_one_pager( + build_company_workbench_html_snapshot(_inputs()), + heading_level=2, + ) + ) + assert forbidden.lower() not in rendered.lower() + + +def test_evidence_one_pager_keeps_withheld_values_non_numeric(): + snapshot = build_company_workbench_html_snapshot(_inputs()) + sentinels = tuple(101001.1 + index for index in range(12)) + base = next(scenario for scenario in snapshot.scenarios if scenario.name == "Base") + withheld_bridge = replace( + base.bridge, + state="withheld", + enterprise_state="withheld", + equity_state="withheld", + per_share_state="withheld", + explicit_total_state="withheld", + projected_fcfs=(sentinels[0],), + discounted_fcfs=(sentinels[1],), + discounted_explicit_total=sentinels[2], + terminal_value=sentinels[3], + discounted_terminal_value=sentinels[4], + enterprise_value=sentinels[5], + cash=sentinels[6], + debt=sentinels[7], + net_debt=sentinels[8], + equity_value=sentinels[9], + shares_outstanding=sentinels[10], + scenario_value_per_share=sentinels[11], + blockers=("The supplied Base bridge is withheld.",), + ) + withheld = replace( + snapshot, + scenarios=tuple( + replace(scenario, bridge=withheld_bridge) + if scenario.name == "Base" + else scenario + for scenario in snapshot.scenarios + ), + ) + rendered = html_brief._html_evidence_one_pager(withheld, heading_level=2) + assert "Scenario value withheld" in rendered + assert "The supplied Base bridge is withheld." in html.unescape(rendered) + assert not [ + value + for value in sentinels + if html_brief.format_html_brief_number(value) in html.unescape(rendered) + ] + + +def test_evidence_one_pager_preserves_share_basis_state_without_using_it_as_gate(): + snapshot = build_company_workbench_html_snapshot(_inputs()) + scenarios = tuple( + replace( + scenario, + bridge=replace( + scenario.bridge, + per_share_state="available", + share_basis_state="unverified", + scenario_value_per_share=31415.92, + ), + ) + for scenario in snapshot.scenarios + ) + rendered = html.unescape( + html_brief._html_evidence_one_pager( + replace(snapshot, scenarios=scenarios), heading_level=2 + ) + ) + assert html_brief.format_html_brief_number(31415.92) in rendered + assert rendered.count("Share basis state: unverified") >= 3 + + +def test_share_basis_state_cannot_override_withheld_per_share_gate(): + snapshot = build_company_workbench_html_snapshot(_inputs()) + changed = replace( + snapshot, + scenarios=tuple( + replace( + scenario, + bridge=replace( + scenario.bridge, + per_share_state="withheld", + share_basis_state="available", + scenario_value_per_share=27182.81, + ), + ) + if scenario.name == "Base" + else scenario + for scenario in snapshot.scenarios + ), + ) + rendered = html.unescape( + html_brief._html_evidence_one_pager(changed, heading_level=2) + ) + assert html_brief.format_html_brief_number(27182.81) not in rendered + assert "Share basis state: available" in rendered + + +@pytest.mark.parametrize( + ("state", "label"), + ( + ("available", "complete"), + ("partial", "partial"), + ("stale", "stale"), + ("withheld", "withheld"), + ), +) +def test_evidence_one_pager_keeps_independent_state_text_visible(state, label): + snapshot = build_company_workbench_html_snapshot(_inputs()) + changed = replace( + snapshot, + freshness_state=state, + answers=tuple(replace(answer, state=state) for answer in snapshot.answers), + ) + rendered = html.unescape( + html_brief._html_evidence_one_pager(changed, heading_level=2) + ) + assert f'data-state="{state}"' in rendered + assert f"State: {label}" in rendered + + +def test_evidence_one_pager_formats_only_supplied_scenario_values(): + snapshot = build_company_workbench_html_snapshot(_inputs()) + scenarios = tuple( + replace( + scenario, + revenue_growth=0.123, + fcf_margin=0.234, + wacc=0.087, + terminal_growth=0.031, + forecast_years=7, + ) + for scenario in snapshot.scenarios + ) + rendered = html.unescape( + html_brief._html_evidence_one_pager( + replace(snapshot, scenarios=scenarios), + heading_level=2, + ) + ) + for supplied in ("12.3%", "23.4%", "8.7%", "3.1%", "7"): + assert supplied in rendered + + +def test_evidence_one_pager_has_summary_scoped_semantics_and_dom_order(): + rendered = html_brief._html_evidence_one_pager( + build_company_workbench_html_snapshot(_inputs()), + heading_level=2, + ) + parser = _OnePagerHtmlParser() + parser.feed(rendered) + assert parser.tags[0] == "section" + assert {"header", "section", "ol", "table", "caption", "aside"} <= set( + parser.tags + ) + assert not {"main", "footer", "script", "form", "iframe"} & set(parser.tags) + assert parser.headings[0] == ("h2", "NVDA Evidence One-Pager") + assert "Portable evidence provenance" in parser.captions + assert parser.labelled_asides == ["evidence-one-pager-provenance-title"] + assert parser.answer_item_count == 4 + assert parser.scenario_item_count == 3 + assert len(parser.state_nodes) == len(parser.state_roles) + assert len(parser.state_roles) == len(set(parser.state_roles)) + assert all(role and state for role, state in parser.state_role_pairs) + assert len(parser.share_basis_pairs) == 4 + markers = ( + 'data-section="one-pager-header"', + 'data-section="one-pager-answers"', + 'data-section="one-pager-scenarios"', + 'data-section="one-pager-research-case"', + 'data-section="one-pager-operating-valuation"', + 'data-section="one-pager-break-case"', + 'data-section="one-pager-questions"', + 'data-section="one-pager-provenance"', + 'data-section="one-pager-handoff"', + ) + assert [rendered.index(marker) for marker in markers] == sorted( + rendered.index(marker) for marker in markers + ) + + +def test_evidence_one_pager_escapes_and_withholds_unsafe_answer_content(): + snapshot = build_company_workbench_html_snapshot(_inputs()) + unsafe = replace( + snapshot.answers[0], + title="", + body="/private/repository buy shares", + ) + rendered = html_brief._html_evidence_one_pager( + replace(snapshot, answers=(unsafe,) + snapshot.answers[1:]), + heading_level=2, + ) + parser = _OnePagerHtmlParser() + parser.feed(rendered) + + assert "