From 34fdb8928a9f615281bea146713655f291180c4b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 00:08:02 +0900 Subject: [PATCH 1/5] feat: seed a TEPP analysis run through tepp_client (v0.84.0) Buyer gap: home Analysis runs only showed lineage reconstruction. make seed now records a Demo Corp TEPP measurement via tepp_client. The default transport is unavailable, so the row is Failed / tepp_not_available -- never a fabricated theta. TEPP stays a wire client, not a local psychometric engine. --- ARCHITECTURE.md | 6 +- CHANGELOG.d/0.84.0-tepp-analysis-run.md | 4 + CHANGELOG.md | 10 ++ frontend/package.json | 2 +- frontend/src/App.test.tsx | 20 ++++ lineageweave/__init__.py | 2 +- pyproject.toml | 2 +- scripts/seed_demo_data.py | 121 ++++++++++++++++++++++++ tests/test_seed_tepp_run.py | 9 ++ uv.lock | 2 +- 10 files changed, 173 insertions(+), 5 deletions(-) create mode 100644 CHANGELOG.d/0.84.0-tepp-analysis-run.md create mode 100644 tests/test_seed_tepp_run.py diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index bedeba285..362883dfa 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -465,7 +465,11 @@ thread-group scope is visible only when the account can already see a post in that group; `all_visible` is requester-only. Hidden runs 404. Detail also lists ABAC-visible post titles in the run's scope whose `created_at` is at or before `knowledge_cutoff` (ADR 0016) so a buyer can open a post the run was allowed to know -without seeing later live rows or hidden bodies. +without seeing later live rows or hidden bodies. Detail also returns +revision and configuration digest prefixes. +`make seed` also records a TEPP measurement run through +`tepp_client`; the default transport is unavailable, so that run is +Failed / `tepp_not_available` rather than a fabricated score. The home list is clickable: `GET /api/analysis-runs/{id}` fills a labeled detail (cutoff, requested date, counts, status history) without exposing a DSN or raw record. Status history is detail-only diff --git a/CHANGELOG.d/0.84.0-tepp-analysis-run.md b/CHANGELOG.d/0.84.0-tepp-analysis-run.md new file mode 100644 index 000000000..18267d698 --- /dev/null +++ b/CHANGELOG.d/0.84.0-tepp-analysis-run.md @@ -0,0 +1,4 @@ +# 0.84.0 TEPP analysis-run seed + +Seed writes `analysis_run_tepp` via `tepp_client`. Missing transport +is Failed / `tepp_not_available`, not a fake measurement. diff --git a/CHANGELOG.md b/CHANGELOG.md index d0edb5d09..b2a57b24a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes to this project are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.84.0] - 2026-08-16 + +### Added + +- `make seed` records a Demo Corp TEPP measurement run through + `tepp_client`. The default transport is unavailable, so the home + list shows "TEPP measurement · Failed · Demo Corp" with + `tepp_not_available` -- never a fabricated theta. TEPP stays a + wire client, not a local psychometric engine. + ## [0.83.0] - 2026-08-16 ### Fixed diff --git a/frontend/package.json b/frontend/package.json index d1e24268f..c21ed209f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.83.0", + "version": "0.84.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index a3da5de6b..4e5fe7da1 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -236,6 +236,25 @@ describe("App, authenticated", () => { }, ], }, + { + analysis_run_id: "run-demo-tepp", + run_kind_code: "analysis_run_tepp", + run_kind_label: "TEPP measurement", + scope_kind_code: "analysis_scope_corporate_entity", + scope_kind_label: "Corporate entity", + scope_entity_name: "Demo Corp", + status_code: "analysis_status_failed", + status_label: "Failed", + knowledge_cutoff: "2026-01-12T12:00:00Z", + requested_at: "2026-01-12T12:34:00Z", + source_counts: [ + { + count_type_code: "analysis_count_document", + count_type_label: "Documents", + count_value: 3, + }, + ], + }, ], }), ); @@ -1374,6 +1393,7 @@ describe("App, authenticated", () => { expect(await screen.findByRole("heading", { name: "Analysis runs" })).toBeInTheDocument(); const list = screen.getByRole("list", { name: "Analysis runs" }); expect(list).toHaveTextContent("Lineage reconstruction · Succeeded · Demo Corp"); + expect(list).toHaveTextContent("TEPP measurement · Failed · Demo Corp"); expect(list).toHaveTextContent("3 documents"); expect(list).not.toHaveTextContent("postgresql://"); expect(list).not.toHaveTextContent("select "); diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index 5bd7638d6..e89edfd07 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "0.83.0" +__version__ = "0.84.0" diff --git a/pyproject.toml b/pyproject.toml index f7b33f6ce..ed229d426 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.83.0" +version = "0.84.0" description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication." readme = "README.md" license = { text = "MIT" } diff --git a/scripts/seed_demo_data.py b/scripts/seed_demo_data.py index cb7c74f87..75a7330f3 100644 --- a/scripts/seed_demo_data.py +++ b/scripts/seed_demo_data.py @@ -333,6 +333,11 @@ def seed( account_ids["demo.analyst"], corporate_entity_id, ) + _seed_demo_tepp_run( + cur, + account_ids["demo.analyst"], + corporate_entity_id, + ) conn.commit() finally: @@ -1298,6 +1303,122 @@ def _seed_demo_analysis_run(cur, requested_by_account_id, corporate_entity_id) - ) +def tepp_seed_outcome() -> tuple[str, str | None]: + """Ask TEPP through the published client. A missing transport is Failed. + + Never invents a psychometric score. ``tepp_not_available`` means the + channel was dropped, not a calibrated negative result. + """ + from lineageweave.tepp_client import AnalysisRunRequest, TeppClient, TeppNotAvailable + + request = AnalysisRunRequest( + idempotency_key="demo-tepp-seed-2026-w02", + tenant_workspace_id="demo-workspace", + snapshot_id="demo-source-contract-v1", + knowledge_cutoff="2026-01-12T12:00:00Z", + model_contract_version="tepp-analysis-run-v1", + output_profile="calibrated_event_measurement", + ) + try: + TeppClient().submit_analysis_run(request) + except TeppNotAvailable: + return "analysis_status_failed", "tepp_not_available" + return "analysis_status_succeeded", None + + +def _seed_demo_tepp_run(cur, requested_by_account_id, corporate_entity_id) -> None: + """Insert one Demo-Corp TEPP run so the kind is visible without a live TEPP. + + Uses :func:`tepp_seed_outcome`. Default transport is unavailable, so + the run ends Failed / ``tepp_not_available`` -- never a fake theta. + """ + import hashlib + + digest = hashlib.sha256(b"lineageweave-synthetic-tepp-snapshot-v1").hexdigest() + cur.execute( + "select analysis_source_snapshot_id from analysis_source_snapshot " + "where snapshot_sha256 = %s", + (digest,), + ) + snapshot_row = cur.fetchone() + if snapshot_row is None: + cur.execute( + """ + insert into analysis_source_snapshot + (snapshot_sha256, source_contract_version, + maximum_available_time, captured_at) + values (%s, 'demo-tepp-contract-v1', + '2026-01-12T00:00:00Z', '2026-01-12T00:05:00Z') + returning analysis_source_snapshot_id + """, + (digest,), + ) + snapshot_id = cur.fetchone()[0] + else: + snapshot_id = snapshot_row[0] + cur.execute( + """ + insert into analysis_source_count + (analysis_source_snapshot_id, count_type_code, count_value) + values (%s, 'analysis_count_document', 3) + on conflict do nothing + """, + (snapshot_id,), + ) + cur.execute( + """ + select analysis_run_id from analysis_run + where requested_by_account_id = %s + and idempotency_key = 'demo-tepp-seed-2026-w02' + """, + (requested_by_account_id,), + ) + run_row = cur.fetchone() + if run_row is None: + cur.execute( + """ + insert into analysis_run + (analysis_source_snapshot_id, run_kind_code, idempotency_key, + requested_by_account_id, knowledge_cutoff, + configuration_schema_version, configuration_sha256, + code_revision_sha, requested_at) + values (%s, 'analysis_run_tepp', 'demo-tepp-seed-2026-w02', + %s, '2026-01-12T12:00:00Z', 'tepp-run-v1', %s, %s, + '2026-01-12T12:34:00Z') + returning analysis_run_id + """, + (snapshot_id, requested_by_account_id, "d" * 64, "e" * 40), + ) + run_id = cur.fetchone()[0] + else: + run_id = run_row[0] + cur.execute( + """ + insert into analysis_run_scope + (analysis_run_id, scope_kind_code, corporate_entity_id) + values (%s, 'analysis_scope_corporate_entity', %s) + on conflict (analysis_run_id) do nothing + """, + (run_id, corporate_entity_id), + ) + final_status, failure_code = tepp_seed_outcome() + events = [ + (1, "analysis_status_pending", "2026-01-12T12:35:00Z", None), + (2, "analysis_status_running", "2026-01-12T12:36:00Z", None), + (3, final_status, "2026-01-12T12:37:00Z", failure_code), + ] + for ordinal, status, occurred, fail in events: + cur.execute( + """ + insert into analysis_run_status_event + (analysis_run_id, status_ordinal, status_code, occurred_at, failure_code) + values (%s, %s, %s, %s, %s) + on conflict do nothing + """, + (run_id, ordinal, status, occurred, fail), + ) + + def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--postgres-dsn", default=DEFAULT_POSTGRES_DSN) diff --git a/tests/test_seed_tepp_run.py b/tests/test_seed_tepp_run.py new file mode 100644 index 000000000..59a22699a --- /dev/null +++ b/tests/test_seed_tepp_run.py @@ -0,0 +1,9 @@ +"""Seeded TEPP analysis runs go through tepp_client, never a local model.""" + +from scripts.seed_demo_data import tepp_seed_outcome + + +def test_tepp_seed_outcome_is_unavailable_not_a_fake_score() -> None: + status, failure = tepp_seed_outcome() + assert status == "analysis_status_failed" + assert failure == "tepp_not_available" diff --git a/uv.lock b/uv.lock index 06408c2a9..411243f56 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "0.83.0" +version = "0.84.0" source = { virtual = "." } dependencies = [ { name = "certifi" }, From 934e6e00231a60673d533742e80454d4a42542eb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:30:20 +0000 Subject: [PATCH 2/5] fix: fail-closed TEPP seed on the shared Demo Corp snapshot #111 still marked a live unused envelope Succeeded, named a different capture than the registry row, and re-inserted frozen counts. Seed now reuses the lineage snapshot (ADR 0013), skips count inserts after the first run, and keeps missing or unused TEPP Failed. The home list tells the operator to open the run and connect TEPP; detail history keeps tepp_not_available. Co-authored-by: Seongho Bae --- AGENTS.md | 5 +- ARCHITECTURE.md | 13 +- CHANGELOG.d/0.84.0-tepp-analysis-run.md | 6 +- CHANGELOG.md | 12 +- CLAUDE.md | 14 ++ .../0013-normalized-analysis-run-registry.md | 7 +- docs/adr/0014-authorized-analysis-run-read.md | 19 +- frontend/src/App.test.tsx | 61 ++++++ frontend/src/App.tsx | 79 ++++++-- scripts/seed_demo_data.py | 184 ++++++++++-------- tests/test_seed_tepp_run.py | 80 +++++++- 11 files changed, 368 insertions(+), 112 deletions(-) create mode 100644 CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md index 9b7d3195b..dba1c4b42 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,7 +55,10 @@ summary/chat, or invented commitment. A missing signal and a confidently-negative signal are different things. Keyman extraction, entity-relationship classification, post summary, in-popup chat, and commitment derivation go through contextual-orchestrator the same way -adjudication does -- never a raw LLM API. +adjudication does -- never a raw LLM API. Demo TEPP seed goes through +`tepp_client` the same way: a missing transport or an unused accepted +envelope is Failed (`tepp_not_available` / `tepp_result_not_persisted`), +never a fabricated theta or a local psychometric substitute. ## Tests diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 362883dfa..b662b00b1 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -468,17 +468,22 @@ run's scope whose `created_at` is at or before `knowledge_cutoff` without seeing later live rows or hidden bodies. Detail also returns revision and configuration digest prefixes. `make seed` also records a TEPP measurement run through -`tepp_client`; the default transport is unavailable, so that run is -Failed / `tepp_not_available` rather than a fabricated score. +`tepp_client` on that same snapshot; the default transport is +unavailable, so that run is Failed rather than a fabricated score. The home list is clickable: `GET /api/analysis-runs/{id}` fills a labeled detail (cutoff, requested date, counts, status history) without exposing a DSN or raw record. Status history is detail-only and uses lookup labels plus occurrence times; a failure event keeps -its machine `failure_code` rather than an invented caption. The +its machine `failure_code` rather than an invented caption. Failed +list rows add a next-action line (open the run, then connect the +measurement service) so `tepp_not_available` is not mistaken for a +calibrated negative result. The payload is lookup labels plus non-negative aggregate counts -- never source SQL, a DSN, a raw record, or a provider body. After `make seed`, Demo Analyst and Demo Admin see "Lineage reconstruction · Succeeded · -Demo Corp" with "3 documents" and Pending / Running / Succeeded times. +Demo Corp" with "3 documents" and Pending / Running / Succeeded times, +and "TEPP measurement · Failed · Demo Corp" whose detail history ends +in Failed / `tepp_not_available`. ## Phase 6a: fast-mlsirm dependency + Rust toolchain (infra only) diff --git a/CHANGELOG.d/0.84.0-tepp-analysis-run.md b/CHANGELOG.d/0.84.0-tepp-analysis-run.md index 18267d698..080cc8240 100644 --- a/CHANGELOG.d/0.84.0-tepp-analysis-run.md +++ b/CHANGELOG.d/0.84.0-tepp-analysis-run.md @@ -1,4 +1,6 @@ # 0.84.0 TEPP analysis-run seed -Seed writes `analysis_run_tepp` via `tepp_client`. Missing transport -is Failed / `tepp_not_available`, not a fake measurement. +Seed writes `analysis_run_tepp` via `tepp_client` on the shared Demo +Corp snapshot. The home list shows Failed and the next action; detail +history keeps `tepp_not_available`. Missing transport is not a fake +measurement. diff --git a/CHANGELOG.md b/CHANGELOG.md index b2a57b24a..22f4878b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +9,14 @@ All notable changes to this project are documented here. Format follows ### Added - `make seed` records a Demo Corp TEPP measurement run through - `tepp_client`. The default transport is unavailable, so the home - list shows "TEPP measurement · Failed · Demo Corp" with - `tepp_not_available` -- never a fabricated theta. TEPP stays a - wire client, not a local psychometric engine. + `tepp_client` on the same snapshot as the lineage run (ADR 0013). + The default transport is unavailable, so the home list shows + "TEPP measurement · Failed · Demo Corp" and tells the operator to + open the run, then connect the measurement service. Detail history + keeps `tepp_not_available` -- never a fabricated theta. TEPP stays + a wire client, not a local psychometric engine. `make seed` skips + snapshot-count inserts once counts exist so a re-run does not hit + the freeze trigger. ## [0.83.0] - 2026-08-16 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..3af72ad45 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,14 @@ +# CLAUDE.md + +Tool-specific pointer. Policy lives in [AGENTS.md](AGENTS.md) and the +ADRs under `docs/adr/`. Do not fork those rules here. + +## Analysis-run seed (v0.84.0) + +`make seed` writes a Demo Corp lineage run and a TEPP run on the same +snapshot (ADR 0013). The TEPP path goes through `tepp_client`. A missing +transport or an unused accepted envelope is Failed +(`tepp_not_available` / `tepp_result_not_persisted`). Do not invent a +theta or a local psychometric substitute. The home list caption stays +`kind · status · entity`; the machine failure code is detail-only +(ADR 0014). Open the Failed row, then connect a live TEPP transport. diff --git a/docs/adr/0013-normalized-analysis-run-registry.md b/docs/adr/0013-normalized-analysis-run-registry.md index 1d5a59866..631c07cd2 100644 --- a/docs/adr/0013-normalized-analysis-run-registry.md +++ b/docs/adr/0013-normalized-analysis-run-registry.md @@ -242,7 +242,12 @@ Acceptance requires: read-only administrator surface. 3. Add a normalized PostgreSQL outbox and Valkey delivery worker. 4. Add TEPP and contextual-orchestrator adapters only after their versioned - contracts are present on reviewed main branches. + contracts are present on reviewed main branches. Seed now records a + Failed TEPP run through `tepp_client` on the shared Demo Corp snapshot; + a live transport remains a later slice. A missing or unused TEPP + envelope must stay Failed (`tepp_not_available` / + `tepp_result_not_persisted`) and must not write a local psychometric + substitute. 5. Execute private actual-data analysis and store only signed aggregate and reproducibility manifests outside public source control. 6. Run browser E2E through real OIDC, product navigation, and evidence drill-down. diff --git a/docs/adr/0014-authorized-analysis-run-read.md b/docs/adr/0014-authorized-analysis-run-read.md index c0f32beef..dea201bfc 100644 --- a/docs/adr/0014-authorized-analysis-run-read.md +++ b/docs/adr/0014-authorized-analysis-run-read.md @@ -38,14 +38,23 @@ LineageWeave owns a fail-closed read projection of the #89 registry: ## Consequences -`make seed` writes one synthetic Demo Corp lineage run so the existing -React home page can show Analysis runs without a second application. -The detail now shows the legal lifecycle the registry already stored. -Write/rebuild APIs, TEPP submission, and a fuller Analysis Run Console -remain later slices. +`make seed` writes one synthetic Demo Corp lineage run and one TEPP +run on the same snapshot so the existing React home page can show both +kinds without a second application. The TEPP run is Failed / +`tepp_not_available` when the default transport is missing -- the list +keeps that machine code off the caption (this decision) and instead +tells the operator to open the run, then connect the measurement +service. The detail now shows the legal lifecycle the registry already +stored. Write/rebuild APIs, a live TEPP transport, and a fuller +Analysis Run Console remain later slices. ## References +American Educational Research Association, American Psychological +Association, & National Council on Measurement in Education. (2014). +*Standards for educational and psychological testing*. American +Educational Research Association. + Lebo, T., Sahoo, S., & McGuinness, D. (Eds.). (2013). *PROV-O: The PROV ontology* (W3C Recommendation). World Wide Web Consortium. https://www.w3.org/TR/2013/REC-prov-o-20130430/ diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 4e5fe7da1..e2a30c684 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -169,6 +169,51 @@ describe("App, authenticated", () => { jsonResponse({ post_id: "post-1", has_commitment: true, ticket }), ); } + if (url.endsWith("/api/analysis-runs/run-demo-tepp")) { + return Promise.resolve( + jsonResponse({ + analysis_run_id: "run-demo-tepp", + run_kind_code: "analysis_run_tepp", + run_kind_label: "TEPP measurement", + scope_kind_code: "analysis_scope_corporate_entity", + scope_kind_label: "Corporate entity", + scope_entity_name: "Demo Corp", + status_code: "analysis_status_failed", + status_label: "Failed", + knowledge_cutoff: "2026-01-12T12:00:00Z", + requested_at: "2026-01-12T12:34:00Z", + source_counts: [ + { + count_type_code: "analysis_count_document", + count_type_label: "Documents", + count_value: 3, + }, + ], + visible_posts: [{ post_id: "post-1", post_title: "Public post" }], + status_history: [ + { + status_ordinal: 1, + status_code: "analysis_status_pending", + status_label: "Pending", + occurred_at: "2026-01-12T12:35:00Z", + }, + { + status_ordinal: 2, + status_code: "analysis_status_running", + status_label: "Running", + occurred_at: "2026-01-12T12:36:00Z", + }, + { + status_ordinal: 3, + status_code: "analysis_status_failed", + status_label: "Failed", + occurred_at: "2026-01-12T12:37:00Z", + failure_code: "tepp_not_available", + }, + ], + }), + ); + } if (url.endsWith("/api/analysis-runs/run-demo-lineage")) { return Promise.resolve( jsonResponse({ @@ -1394,6 +1439,9 @@ describe("App, authenticated", () => { const list = screen.getByRole("list", { name: "Analysis runs" }); expect(list).toHaveTextContent("Lineage reconstruction · Succeeded · Demo Corp"); expect(list).toHaveTextContent("TEPP measurement · Failed · Demo Corp"); + expect(list).toHaveTextContent( + "Open this run to see why it failed, then connect the measurement service and re-run.", + ); expect(list).toHaveTextContent("3 documents"); expect(list).not.toHaveTextContent("postgresql://"); expect(list).not.toHaveTextContent("select "); @@ -1416,6 +1464,19 @@ describe("App, authenticated", () => { await userEvent.click(screen.getByRole("button", { name: "Open run post: Public post" })); await waitFor(() => expect(screen.getByText("The full body text.")).toBeInTheDocument()); + + await userEvent.click( + screen.getByRole("button", { + name: "Open analysis run: TEPP measurement · Failed · Demo Corp", + }), + ); + expect( + await screen.findByRole("heading", { name: "TEPP measurement · Failed · Demo Corp" }), + ).toBeInTheDocument(); + const teppHistory = screen.getByRole("list", { name: "Analysis run status history" }); + expect(teppHistory).toHaveTextContent("Failed 2026-01-12 12:37 · tepp_not_available"); + expect(screen.getByText(/cutoff corpus TEPP would measure/i)).toBeInTheDocument(); + expect(teppHistory).not.toHaveTextContent("Succeeded"); }); it("shows the calibrated period-report mean theta on the home page", async () => { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f866bb302..50602c687 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1353,6 +1353,48 @@ function analysisRunCaption(run: AnalysisRun): string { .join(" · "); } +/** + * Next action for a failed run on the home list. + * + * The machine `failure_code` stays on detail history (ADR 0014). The + * list tells the operator to open the run, then reconnect the service. + */ +function analysisRunNextAction(run: AnalysisRun): string | null { + if (run.status_code === "analysis_status_failed") { + return "Open this run to see why it failed, then connect the measurement service and re-run."; + } + return null; +} + +/** + * Empty-corpus copy that tells the operator what to do next. + */ +function analysisRunEmptyPostsHint(run: AnalysisRun): string { + if (run.run_kind_code === "analysis_run_tepp") { + return ( + "No posts were available at this cutoff for TEPP to measure. " + + "Open a later run, or ask an administrator to capture a newer snapshot." + ); + } + return ( + "No posts were available at this cutoff. Open a later run, or ask an " + + "administrator to capture a newer snapshot." + ); +} + +/** + * Corpus copy for a TEPP run that already has cutoff posts. + * + * Those titles are the measurement bag, not a reconstruction result. + */ +function analysisRunCorpusHint(run: AnalysisRun): string | null { + if (run.run_kind_code !== "analysis_run_tepp") return null; + return ( + "These posts are the cutoff corpus TEPP would measure. Connect a TEPP " + + "transport, then re-run, to replace Failed with a calibrated result." + ); +} + function AnalysisRunsPanel({ accessToken, onSelectPost, @@ -1387,6 +1429,8 @@ function AnalysisRunsPanel({ if (error && runs === null) return

{error}

; if (runs === null) return

Loading analysis runs...

; + const corpusHint = selected ? analysisRunCorpusHint(selected) : null; + return (
@@ -1404,6 +1448,7 @@ function AnalysisRunsPanel({ (count) => count.count_type_code === "analysis_count_document", ); const caption = analysisRunCaption(run); + const nextAction = analysisRunNextAction(run); return (
  • ); @@ -1448,20 +1494,25 @@ function AnalysisRunsPanel({ ))} )} - {selected.visible_posts && selected.visible_posts.length > 0 && ( -
      - {selected.visible_posts.map((post) => ( -
    • - -
    • - ))} -
    + {selected.visible_posts && selected.visible_posts.length > 0 ? ( + <> + {corpusHint &&

    {corpusHint}

    } +
      + {selected.visible_posts.map((post) => ( +
    • + +
    • + ))} +
    + + ) : ( +

    {analysisRunEmptyPostsHint(selected)}

    )}
    )} diff --git a/scripts/seed_demo_data.py b/scripts/seed_demo_data.py index 75a7330f3..f6f575ccc 100644 --- a/scripts/seed_demo_data.py +++ b/scripts/seed_demo_data.py @@ -19,6 +19,7 @@ from __future__ import annotations import argparse +import hashlib import os import sys from pathlib import Path @@ -30,6 +31,7 @@ import psycopg2 from lineageweave.http_client import get_json_list, post_form +from lineageweave.tepp_client import AnalysisRunRequest, TeppClient, TeppNotAvailable REALM = "lineageweave-demo" DEFAULT_POSTGRES_DSN = "postgresql://lineageweave:lineageweave_dev_only@localhost:15432/lineageweave" @@ -37,6 +39,12 @@ DEFAULT_KEYCLOAK_ADMIN_USER = os.environ.get("KEYCLOAK_ADMIN", "admin") DEFAULT_VALKEY_URL = "redis://localhost:16379/0" +# ADR 0013: one Demo Corp capture, many runs (lineage + TEPP). +DEMO_SOURCE_SNAPSHOT_MATERIAL = b"lineageweave-synthetic-demo-snapshot-v1" +DEMO_SOURCE_CONTRACT_VERSION = "demo-source-contract-v1" +DEMO_LINEAGE_IDEMPOTENCY_KEY = "demo-lineage-seed-2026-w02" +DEMO_TEPP_IDEMPOTENCY_KEY = "demo-tepp-seed-2026-w02" + # (post_title, ticket_title, due_date) -- Event Lineage fixtures a report # member click opens. Activity seed uses the same titles so Valkey matches. FIXTURE_TICKET_SPECS = ( @@ -1208,36 +1216,57 @@ def _seed_demo_period_report(cur, author_account_id, corporate_entity_id, proces _persist_seed_period_report(cur, "process_unit", high_key, w03, week3[high_key]) -def _seed_demo_analysis_run(cur, requested_by_account_id, corporate_entity_id) -> None: - """Insert one Demo-Corp lineage run so Analysis runs is not empty. +def demo_source_snapshot_sha256() -> str: + """Return the reusable Demo Corp snapshot digest (never a source row).""" + return hashlib.sha256(DEMO_SOURCE_SNAPSHOT_MATERIAL).hexdigest() - Aggregates only: three synthetic documents, one thread. The digest is - a hash of a fixed demo contract string -- never a source row or DSN. - """ - import hashlib - digest = hashlib.sha256(b"lineageweave-synthetic-demo-snapshot-v1").hexdigest() +def _ensure_demo_source_snapshot(cur): + """Return the shared Demo Corp capture, inserting it on first seed. + + Lineage and TEPP runs share this snapshot (ADR 0013: one capture, + many runs). The digest is a hash of a fixed demo contract string -- + never a source row or DSN. + """ + digest = demo_source_snapshot_sha256() cur.execute( "select analysis_source_snapshot_id from analysis_source_snapshot " "where snapshot_sha256 = %s", (digest,), ) snapshot_row = cur.fetchone() - if snapshot_row is None: - cur.execute( - """ - insert into analysis_source_snapshot - (snapshot_sha256, source_contract_version, - maximum_available_time, captured_at) - values (%s, 'demo-source-contract-v1', - '2026-01-12T00:00:00Z', '2026-01-12T00:05:00Z') - returning analysis_source_snapshot_id - """, - (digest,), - ) - snapshot_id = cur.fetchone()[0] - else: - snapshot_id = snapshot_row[0] + if snapshot_row is not None: + return snapshot_row[0] + cur.execute( + """ + insert into analysis_source_snapshot + (snapshot_sha256, source_contract_version, + maximum_available_time, captured_at) + values (%s, %s, + '2026-01-12T00:00:00Z', '2026-01-12T00:05:00Z') + returning analysis_source_snapshot_id + """, + (digest, DEMO_SOURCE_CONTRACT_VERSION), + ) + return cur.fetchone()[0] + + +def _ensure_demo_source_counts(cur, snapshot_id) -> None: + """Insert demo counts only when the snapshot still has none. + + ``enforce_analysis_source_count_freeze`` runs BEFORE INSERT. After + the first run points at the snapshot, a later ``INSERT ... ON + CONFLICT DO NOTHING`` still raises ``analysis_source_count_frozen_after_run`` + and rolls back the whole ``seed()`` transaction. Skip when counts + already exist so ``make seed`` can be re-run. + """ + cur.execute( + "select 1 from analysis_source_count " + "where analysis_source_snapshot_id = %s limit 1", + (snapshot_id,), + ) + if cur.fetchone() is not None: + return cur.execute( """ insert into analysis_source_count @@ -1247,17 +1276,27 @@ def _seed_demo_analysis_run(cur, requested_by_account_id, corporate_entity_id) - (%s, 'analysis_count_thread', 1), (%s, 'analysis_count_lineage_node', 5), (%s, 'analysis_count_lineage_edge', 4) - on conflict do nothing """, (snapshot_id, snapshot_id, snapshot_id, snapshot_id), ) + + +def _seed_demo_analysis_run(cur, requested_by_account_id, corporate_entity_id) -> None: + """Insert one Demo-Corp lineage run so Analysis runs is not empty. + + Aggregates only: three synthetic documents, one thread. Reuses the + shared Demo Corp snapshot so a later TEPP run can attach to the + same capture. + """ + snapshot_id = _ensure_demo_source_snapshot(cur) + _ensure_demo_source_counts(cur, snapshot_id) cur.execute( """ select analysis_run_id from analysis_run where requested_by_account_id = %s - and idempotency_key = 'demo-lineage-seed-2026-w02' + and idempotency_key = %s """, - (requested_by_account_id,), + (requested_by_account_id, DEMO_LINEAGE_IDEMPOTENCY_KEY), ) run_row = cur.fetchone() if run_row is None: @@ -1268,12 +1307,18 @@ def _seed_demo_analysis_run(cur, requested_by_account_id, corporate_entity_id) - requested_by_account_id, knowledge_cutoff, configuration_schema_version, configuration_sha256, code_revision_sha, requested_at) - values (%s, 'analysis_run_lineage', 'demo-lineage-seed-2026-w02', + values (%s, 'analysis_run_lineage', %s, %s, '2026-01-12T12:00:00Z', 'lineage-run-v1', %s, %s, '2026-01-12T12:30:00Z') returning analysis_run_id """, - (snapshot_id, requested_by_account_id, "b" * 64, "c" * 40), + ( + snapshot_id, + DEMO_LINEAGE_IDEMPOTENCY_KEY, + requested_by_account_id, + "b" * 64, + "c" * 40, + ), ) run_id = cur.fetchone()[0] else: @@ -1303,75 +1348,50 @@ def _seed_demo_analysis_run(cur, requested_by_account_id, corporate_entity_id) - ) -def tepp_seed_outcome() -> tuple[str, str | None]: - """Ask TEPP through the published client. A missing transport is Failed. - - Never invents a psychometric score. ``tepp_not_available`` means the - channel was dropped, not a calibrated negative result. - """ - from lineageweave.tepp_client import AnalysisRunRequest, TeppClient, TeppNotAvailable - - request = AnalysisRunRequest( - idempotency_key="demo-tepp-seed-2026-w02", +def tepp_seed_request() -> AnalysisRunRequest: + """Build the Demo Corp TEPP request against the shared snapshot digest.""" + return AnalysisRunRequest( + idempotency_key=DEMO_TEPP_IDEMPOTENCY_KEY, tenant_workspace_id="demo-workspace", - snapshot_id="demo-source-contract-v1", + snapshot_id=demo_source_snapshot_sha256(), knowledge_cutoff="2026-01-12T12:00:00Z", model_contract_version="tepp-analysis-run-v1", output_profile="calibrated_event_measurement", ) + + +def tepp_seed_outcome(client: TeppClient | None = None) -> tuple[str, str | None]: + """Ask TEPP through the published client. A missing transport is Failed. + + Never invents a psychometric score. ``tepp_not_available`` means the + channel was dropped, not a calibrated negative result. A live + envelope is also not a persistable measurement in this seed, so the + run is not stamped Succeeded. + """ + request = tepp_seed_request() try: - TeppClient().submit_analysis_run(request) + (client or TeppClient()).submit_analysis_run(request) except TeppNotAvailable: return "analysis_status_failed", "tepp_not_available" - return "analysis_status_succeeded", None + return "analysis_status_failed", "tepp_result_not_persisted" def _seed_demo_tepp_run(cur, requested_by_account_id, corporate_entity_id) -> None: """Insert one Demo-Corp TEPP run so the kind is visible without a live TEPP. - Uses :func:`tepp_seed_outcome`. Default transport is unavailable, so - the run ends Failed / ``tepp_not_available`` -- never a fake theta. + Uses :func:`tepp_seed_outcome` against the shared lineage snapshot. + Default transport is unavailable, so the run ends Failed / + ``tepp_not_available`` -- never a fake theta. """ - import hashlib - - digest = hashlib.sha256(b"lineageweave-synthetic-tepp-snapshot-v1").hexdigest() - cur.execute( - "select analysis_source_snapshot_id from analysis_source_snapshot " - "where snapshot_sha256 = %s", - (digest,), - ) - snapshot_row = cur.fetchone() - if snapshot_row is None: - cur.execute( - """ - insert into analysis_source_snapshot - (snapshot_sha256, source_contract_version, - maximum_available_time, captured_at) - values (%s, 'demo-tepp-contract-v1', - '2026-01-12T00:00:00Z', '2026-01-12T00:05:00Z') - returning analysis_source_snapshot_id - """, - (digest,), - ) - snapshot_id = cur.fetchone()[0] - else: - snapshot_id = snapshot_row[0] - cur.execute( - """ - insert into analysis_source_count - (analysis_source_snapshot_id, count_type_code, count_value) - values (%s, 'analysis_count_document', 3) - on conflict do nothing - """, - (snapshot_id,), - ) + snapshot_id = _ensure_demo_source_snapshot(cur) + _ensure_demo_source_counts(cur, snapshot_id) cur.execute( """ select analysis_run_id from analysis_run where requested_by_account_id = %s - and idempotency_key = 'demo-tepp-seed-2026-w02' + and idempotency_key = %s """, - (requested_by_account_id,), + (requested_by_account_id, DEMO_TEPP_IDEMPOTENCY_KEY), ) run_row = cur.fetchone() if run_row is None: @@ -1382,12 +1402,18 @@ def _seed_demo_tepp_run(cur, requested_by_account_id, corporate_entity_id) -> No requested_by_account_id, knowledge_cutoff, configuration_schema_version, configuration_sha256, code_revision_sha, requested_at) - values (%s, 'analysis_run_tepp', 'demo-tepp-seed-2026-w02', + values (%s, 'analysis_run_tepp', %s, %s, '2026-01-12T12:00:00Z', 'tepp-run-v1', %s, %s, '2026-01-12T12:34:00Z') returning analysis_run_id """, - (snapshot_id, requested_by_account_id, "d" * 64, "e" * 40), + ( + snapshot_id, + DEMO_TEPP_IDEMPOTENCY_KEY, + requested_by_account_id, + "d" * 64, + "e" * 40, + ), ) run_id = cur.fetchone()[0] else: diff --git a/tests/test_seed_tepp_run.py b/tests/test_seed_tepp_run.py index 59a22699a..c865c475c 100644 --- a/tests/test_seed_tepp_run.py +++ b/tests/test_seed_tepp_run.py @@ -1,9 +1,85 @@ """Seeded TEPP analysis runs go through tepp_client, never a local model.""" -from scripts.seed_demo_data import tepp_seed_outcome +from lineageweave.tepp_client import AnalysisRunRequest, TeppClient, TeppNotAvailable +from scripts.seed_demo_data import ( + _ensure_demo_source_counts, + demo_source_snapshot_sha256, + tepp_seed_outcome, + tepp_seed_request, +) -def test_tepp_seed_outcome_is_unavailable_not_a_fake_score() -> None: +class _RecordingUnavailableClient(TeppClient): + """Default-path stand-in that records the request then drops the channel.""" + + def __init__(self) -> None: + super().__init__() + self.submitted: list[AnalysisRunRequest] = [] + + def submit_analysis_run(self, request: AnalysisRunRequest) -> dict[str, object]: + self.submitted.append(request) + raise TeppNotAvailable("TEPP has no live HTTP endpoint yet.") + + +class _AcceptingClient(TeppClient): + """Transport that returns an envelope without a persistable measurement.""" + + def __init__(self) -> None: + super().__init__(transport=lambda _payload: {"status": "accepted"}) + + +class _CountCursor: + """Minimal cursor for proving re-seed skips a frozen count insert.""" + + def __init__(self, existing_counts: bool) -> None: + self.existing_counts = existing_counts + self.statements: list[str] = [] + + def execute(self, sql: str, _params=None) -> None: + self.statements.append(" ".join(sql.split())) + + def fetchone(self): + if self.existing_counts and "from analysis_source_count" in self.statements[-1]: + return (1,) + return None + + +def test_tepp_seed_request_targets_the_shared_demo_snapshot() -> None: + request = tepp_seed_request() + assert request.snapshot_id == demo_source_snapshot_sha256() + assert request.idempotency_key == "demo-tepp-seed-2026-w02" + assert request.model_contract_version == "tepp-analysis-run-v1" + assert request.output_profile == "calibrated_event_measurement" + + +def test_tepp_seed_outcome_calls_client_and_does_not_invent_a_score() -> None: + client = _RecordingUnavailableClient() + status, failure = tepp_seed_outcome(client) + assert status == "analysis_status_failed" + assert failure == "tepp_not_available" + assert client.submitted == [tepp_seed_request()] + + +def test_tepp_seed_outcome_default_client_is_unavailable_not_a_fake_score() -> None: status, failure = tepp_seed_outcome() assert status == "analysis_status_failed" assert failure == "tepp_not_available" + + +def test_tepp_seed_outcome_does_not_treat_an_empty_envelope_as_success() -> None: + status, failure = tepp_seed_outcome(_AcceptingClient()) + assert status == "analysis_status_failed" + assert failure == "tepp_result_not_persisted" + + +def test_ensure_demo_source_counts_skips_insert_when_counts_exist() -> None: + cursor = _CountCursor(existing_counts=True) + _ensure_demo_source_counts(cursor, "snapshot-1") + assert any("from analysis_source_count" in sql for sql in cursor.statements) + assert not any(sql.lstrip().startswith("insert into analysis_source_count") for sql in cursor.statements) + + +def test_ensure_demo_source_counts_inserts_when_the_snapshot_is_empty() -> None: + cursor = _CountCursor(existing_counts=False) + _ensure_demo_source_counts(cursor, "snapshot-1") + assert any(sql.lstrip().startswith("insert into analysis_source_count") for sql in cursor.statements) From 24d38c091e28668371d717d83b195bcb7a6dbf10 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:35:46 +0000 Subject: [PATCH 3/5] fix: keep failed-run next actions kind-specific A failed lineage row must not tell the operator to connect TEPP. Stacked PRs now run the same GitHub Checks as PRs to main. Co-authored-by: Seongho Bae --- .github/workflows/tests.yml | 1 - ARCHITECTURE.md | 5 ++-- CHANGELOG.d/0.84.0-tepp-analysis-run.md | 6 ++--- CHANGELOG.md | 4 ++- docs/adr/0014-authorized-analysis-run-read.md | 9 ++++--- frontend/src/App.test.tsx | 25 +++++++++++++++++-- frontend/src/App.tsx | 18 +++++++++---- 7 files changed, 50 insertions(+), 18 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 36e24332b..e78d36254 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -4,7 +4,6 @@ on: push: branches: [main] pull_request: - branches: [main] permissions: contents: read diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b662b00b1..2492ee50d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -475,9 +475,10 @@ labeled detail (cutoff, requested date, counts, status history) without exposing a DSN or raw record. Status history is detail-only and uses lookup labels plus occurrence times; a failure event keeps its machine `failure_code` rather than an invented caption. Failed -list rows add a next-action line (open the run, then connect the +TEPP list rows add a next-action line (open the run, then connect the measurement service) so `tepp_not_available` is not mistaken for a -calibrated negative result. The +calibrated negative result. A failed lineage row tells the operator +to retry reconstruction, not to connect TEPP. The payload is lookup labels plus non-negative aggregate counts -- never source SQL, a DSN, a raw record, or a provider body. After `make seed`, Demo Analyst and Demo Admin see "Lineage reconstruction · Succeeded · diff --git a/CHANGELOG.d/0.84.0-tepp-analysis-run.md b/CHANGELOG.d/0.84.0-tepp-analysis-run.md index 080cc8240..c96531899 100644 --- a/CHANGELOG.d/0.84.0-tepp-analysis-run.md +++ b/CHANGELOG.d/0.84.0-tepp-analysis-run.md @@ -1,6 +1,6 @@ # 0.84.0 TEPP analysis-run seed Seed writes `analysis_run_tepp` via `tepp_client` on the shared Demo -Corp snapshot. The home list shows Failed and the next action; detail -history keeps `tepp_not_available`. Missing transport is not a fake -measurement. +Corp snapshot. The home list shows Failed and a kind-specific next +action; detail history keeps `tepp_not_available`. Missing transport +is not a fake measurement. A failed lineage row does not mention TEPP. diff --git a/CHANGELOG.md b/CHANGELOG.md index 22f4878b4..c36b2666d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,9 @@ All notable changes to this project are documented here. Format follows keeps `tepp_not_available` -- never a fabricated theta. TEPP stays a wire client, not a local psychometric engine. `make seed` skips snapshot-count inserts once counts exist so a re-run does not hit - the freeze trigger. + the freeze trigger. A failed lineage row tells the operator to retry + reconstruction; only a failed TEPP row mentions the measurement + service. Stacked PRs now run the same GitHub Checks as PRs to main. ## [0.83.0] - 2026-08-16 diff --git a/docs/adr/0014-authorized-analysis-run-read.md b/docs/adr/0014-authorized-analysis-run-read.md index dea201bfc..841dfb383 100644 --- a/docs/adr/0014-authorized-analysis-run-read.md +++ b/docs/adr/0014-authorized-analysis-run-read.md @@ -43,10 +43,11 @@ run on the same snapshot so the existing React home page can show both kinds without a second application. The TEPP run is Failed / `tepp_not_available` when the default transport is missing -- the list keeps that machine code off the caption (this decision) and instead -tells the operator to open the run, then connect the measurement -service. The detail now shows the legal lifecycle the registry already -stored. Write/rebuild APIs, a live TEPP transport, and a fuller -Analysis Run Console remain later slices. +tells the operator to open the TEPP run, then connect the measurement +service. A failed lineage row tells the operator to retry +reconstruction, not to connect TEPP. The detail now shows the legal +lifecycle the registry already stored. Write/rebuild APIs, a live TEPP +transport, and a fuller Analysis Run Console remain later slices. ## References diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index e2a30c684..5fc04aac9 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -59,6 +59,7 @@ describe("App, authenticated", () => { chatUnavailable?: boolean; searchUnavailable?: boolean; verificationEvidenceUrl?: string | null; + failedLineageRun?: boolean; }) { const statusLabel: Record = { open: "Open", @@ -269,8 +270,10 @@ describe("App, authenticated", () => { scope_kind_code: "analysis_scope_corporate_entity", scope_kind_label: "Corporate entity", scope_entity_name: "Demo Corp", - status_code: "analysis_status_succeeded", - status_label: "Succeeded", + status_code: options?.failedLineageRun + ? "analysis_status_failed" + : "analysis_status_succeeded", + status_label: options?.failedLineageRun ? "Failed" : "Succeeded", knowledge_cutoff: "2026-01-12T12:00:00Z", requested_at: "2026-01-12T12:30:00Z", source_counts: [ @@ -1479,6 +1482,24 @@ describe("App, authenticated", () => { expect(teppHistory).not.toHaveTextContent("Succeeded"); }); + it("does not tell a failed lineage run to connect the measurement service", async () => { + stubBackend({ failedLineageRun: true }); + render(); + + const list = await screen.findByRole("list", { name: "Analysis runs" }); + expect(list).toHaveTextContent("Lineage reconstruction · Failed · Demo Corp"); + expect(list).toHaveTextContent( + "Open this run to see why it failed, then retry reconstruction from a current snapshot.", + ); + expect(list).toHaveTextContent( + "Open this run to see why it failed, then connect the measurement service and re-run.", + ); + const lineageButton = screen.getByRole("button", { + name: "Open analysis run: Lineage reconstruction · Failed · Demo Corp", + }); + expect(lineageButton).not.toHaveTextContent("measurement service"); + }); + it("shows the calibrated period-report mean theta on the home page", async () => { stubBackend(); render(); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 50602c687..e5b5994f9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1356,14 +1356,22 @@ function analysisRunCaption(run: AnalysisRun): string { /** * Next action for a failed run on the home list. * - * The machine `failure_code` stays on detail history (ADR 0014). The - * list tells the operator to open the run, then reconnect the service. + * The machine `failure_code` stays on detail history (ADR 0014). Copy + * is kind-specific so a failed lineage reconstruction is not mistaken + * for a missing TEPP transport. */ function analysisRunNextAction(run: AnalysisRun): string | null { - if (run.status_code === "analysis_status_failed") { - return "Open this run to see why it failed, then connect the measurement service and re-run."; + if (run.status_code !== "analysis_status_failed") { + return null; + } + switch (run.run_kind_code) { + case "analysis_run_tepp": + return "Open this run to see why it failed, then connect the measurement service and re-run."; + case "analysis_run_lineage": + return "Open this run to see why it failed, then retry reconstruction from a current snapshot."; + default: + return "Open this run to see why it failed, then retry after the blocking service is connected."; } - return null; } /** From a2ff249d0221eb89d0e15008f5a5c469f43d4ca3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:35:51 +0000 Subject: [PATCH 4/5] docs: keep TEPP next-action copy off failed lineage rows Co-authored-by: Seongho Bae --- CLAUDE.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3af72ad45..0a2950e91 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,4 +11,6 @@ transport or an unused accepted envelope is Failed (`tepp_not_available` / `tepp_result_not_persisted`). Do not invent a theta or a local psychometric substitute. The home list caption stays `kind · status · entity`; the machine failure code is detail-only -(ADR 0014). Open the Failed row, then connect a live TEPP transport. +(ADR 0014). Open a Failed TEPP row, then connect a live TEPP +transport. A failed lineage row retries reconstruction -- it does not +mention TEPP. From 5aa3c8e7f80b28a5f148adf1fef2d2cbde554a85 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 15:37:18 +0000 Subject: [PATCH 5/5] fix: keep TEPP corpus hint off a succeeded measurement A calibrated TEPP row must not tell the operator to replace Failed. Co-authored-by: Seongho Bae --- frontend/src/App.test.tsx | 38 +++++++++++++++++++++++++++++++------- frontend/src/App.tsx | 11 +++++++---- 2 files changed, 38 insertions(+), 11 deletions(-) diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 5fc04aac9..c95abf160 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -60,6 +60,7 @@ describe("App, authenticated", () => { searchUnavailable?: boolean; verificationEvidenceUrl?: string | null; failedLineageRun?: boolean; + succeededTeppRun?: boolean; }) { const statusLabel: Record = { open: "Open", @@ -179,8 +180,10 @@ describe("App, authenticated", () => { scope_kind_code: "analysis_scope_corporate_entity", scope_kind_label: "Corporate entity", scope_entity_name: "Demo Corp", - status_code: "analysis_status_failed", - status_label: "Failed", + status_code: options?.succeededTeppRun + ? "analysis_status_succeeded" + : "analysis_status_failed", + status_label: options?.succeededTeppRun ? "Succeeded" : "Failed", knowledge_cutoff: "2026-01-12T12:00:00Z", requested_at: "2026-01-12T12:34:00Z", source_counts: [ @@ -206,10 +209,14 @@ describe("App, authenticated", () => { }, { status_ordinal: 3, - status_code: "analysis_status_failed", - status_label: "Failed", + status_code: options?.succeededTeppRun + ? "analysis_status_succeeded" + : "analysis_status_failed", + status_label: options?.succeededTeppRun ? "Succeeded" : "Failed", occurred_at: "2026-01-12T12:37:00Z", - failure_code: "tepp_not_available", + ...(options?.succeededTeppRun + ? {} + : { failure_code: "tepp_not_available" }), }, ], }), @@ -291,8 +298,10 @@ describe("App, authenticated", () => { scope_kind_code: "analysis_scope_corporate_entity", scope_kind_label: "Corporate entity", scope_entity_name: "Demo Corp", - status_code: "analysis_status_failed", - status_label: "Failed", + status_code: options?.succeededTeppRun + ? "analysis_status_succeeded" + : "analysis_status_failed", + status_label: options?.succeededTeppRun ? "Succeeded" : "Failed", knowledge_cutoff: "2026-01-12T12:00:00Z", requested_at: "2026-01-12T12:34:00Z", source_counts: [ @@ -1500,6 +1509,21 @@ describe("App, authenticated", () => { expect(lineageButton).not.toHaveTextContent("measurement service"); }); + it("does not tell a succeeded TEPP run to replace Failed", async () => { + stubBackend({ succeededTeppRun: true }); + render(); + + await userEvent.click( + await screen.findByRole("button", { + name: "Open analysis run: TEPP measurement · Succeeded · Demo Corp", + }), + ); + expect( + await screen.findByText("These posts are the cutoff corpus this TEPP run measured."), + ).toBeInTheDocument(); + expect(screen.queryByText(/replace Failed/i)).not.toBeInTheDocument(); + }); + it("shows the calibrated period-report mean theta on the home page", async () => { stubBackend(); render(); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e5b5994f9..a5f66d7a9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1397,10 +1397,13 @@ function analysisRunEmptyPostsHint(run: AnalysisRun): string { */ function analysisRunCorpusHint(run: AnalysisRun): string | null { if (run.run_kind_code !== "analysis_run_tepp") return null; - return ( - "These posts are the cutoff corpus TEPP would measure. Connect a TEPP " + - "transport, then re-run, to replace Failed with a calibrated result." - ); + if (run.status_code === "analysis_status_failed") { + return ( + "These posts are the cutoff corpus TEPP would measure. Connect a TEPP " + + "transport, then re-run, to replace Failed with a calibrated result." + ); + } + return "These posts are the cutoff corpus this TEPP run measured."; } function AnalysisRunsPanel({