diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 34cbf90b5..61d18a163 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -69,6 +69,7 @@ flowchart LR | `rankweave_client.py` | Fail-closed RankWeave ranking port (`weighted_reciprocal_rank_fuse` in-process; never invent a fused score or a theta) | | `reconstruct.py` | The pipeline: group → candidate window → score → fuse → thread | | `lineage_persistence.py` | Flattens reconstruct trees into `post_lineage_edge` row specs (parent, child, fused_score) | +| `interval_relation.py` | Allen (1983) closed interval relations for those edges. Each post is a point interval on its observed UTC `created_at` day; mutable ticket dates are not Event Lineage evidence. | | `knowledge_graph.py` | Random-walk-with-restart relevance + per-node adaptive related-node cutoff (Tong et al., 2006) -- pure graph math, no Postgres | | `keyman_extraction.py` | Pluggable LLM extraction of two-sided (our-side/counterparty) person mentions + N:N org affiliations from a post | | `entity_relationship_classification.py` | Pluggable LLM classification of a named organization's relationship to the post author (`rel_voc`/`rel_vom`/`rel_vop`/`rel_vocc`/`rel_voco`/`rel_vos`) | @@ -224,9 +225,12 @@ contextual-orchestrator; persist is `backend/app/keyman_ingestion.py`. `GET /api/lineage` returns the ABAC-filtered reconstruct graph (`{nodes, edges}`) from persisted `post_lineage_edge` rows. Each node includes `group` from the same `reconstruct_group_key()` rebuild uses -(persisted `thread_group_key`, else process unit, else corp). +(persisted `thread_group_key`, else process unit, else corp). Each +direct edge includes `interval_relation_code` / `interval_relation_label` +(Allen, 1983; ADR 0161) computed from the two posts' observed windows. `POST /api/lineage/rebuild` (`post_admin`) re-runs `reconstruct()` over -every `source_post` and rewrites those edges. Reconstruct grouping is +every `source_post` and rewrites those edges, then names the interval +relation in the same transaction. Reconstruct grouping is stored on the post as `thread_group_key` / `secondary_grouping_key` (not derived from process unit or voc type). diff --git a/CHANGELOG.d/2.15.1-event-lineage-interval-relation.md b/CHANGELOG.d/2.15.1-event-lineage-interval-relation.md new file mode 100644 index 000000000..74504ef3f --- /dev/null +++ b/CHANGELOG.d/2.15.1-event-lineage-interval-relation.md @@ -0,0 +1,19 @@ +# 2.15.1 — Name Allen interval relations on Event Lineage edges + +## Added + +- Persist `interval_relation_code` on `post_lineage_edge` (ADR 0161). After + reconstruct chooses a parent, the product names the Allen (1983) relation + between the two posts' dated windows. Every post is a point interval on its + observed UTC creation day; mutable ticket dates are ignored. +- After `make seed`, the A-100 pricing follow-up is **Before** the revised + quote and delivery question. Click the Before row for the revised quote to + open it. The DAG shows those labels as visible text and as a keyboard list. + Indirect Keyman links stay unlabeled. + +## Changed + +- `GET /api/lineage` and `GET /api/posts/{id}/lineage` return the exact + lookup label next to the fused score. Opening the child orients the + stored parent→child code (Contains → During) so the opened post is the + subject of the relation. diff --git a/CHANGELOG.md b/CHANGELOG.md index f813967d8..3dd01118f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -131,6 +131,15 @@ All notable changes to this project are documented here. Format follows on the cited evidence. Open that cited post to read which clock matched. Never invent a theta or an event date. +## [2.15.1] - 2026-08-25 + +### Added + +- Event Lineage edges now name the Allen (1983) interval relation between + posts' observed UTC creation-day points. Mutable ticket due dates do not + rewrite Event Lineage evidence; directed relation rows open the other post, + while indirect Keyman links remain unlabeled (ADR 0161). + ## [2.15.0] - 2026-08-25 ### Changed diff --git a/backend/app/lineage_ingestion.py b/backend/app/lineage_ingestion.py index 90a993ac3..7d3107db1 100644 --- a/backend/app/lineage_ingestion.py +++ b/backend/app/lineage_ingestion.py @@ -19,6 +19,12 @@ import asyncpg from backend.app.post_eligibility import SOURCE_POST_ELIGIBILITY_SQL +from lineageweave.interval_relation import ( + INTERVAL_RELATION_LABELS, + allen_interval_relation, + interval_from_post, + interval_relation_from_current, +) from lineageweave.lineage_persistence import lineage_edge_specs from lineageweave.models import Edge, Record @@ -72,16 +78,46 @@ def records_from_source_posts(rows: list[Mapping[str, Any]]) -> list[Record]: return records -async def persist_lineage_edges(conn: asyncpg.Connection, edges: list[Edge]) -> None: +def interval_relation_code_for_edge( + parent_row: Mapping[str, Any], child_row: Mapping[str, Any] +) -> str: + """Allen relation of the parent creation-day point toward the child.""" + return allen_interval_relation( + interval_from_post(parent_row["created_at"]), + interval_from_post(child_row["created_at"]), + ) + + +async def persist_lineage_edges( + conn: asyncpg.Connection, + edges: list[Edge], + points_by_post_id: Mapping[str, Mapping[str, Any]], +) -> None: """Replace ``post_lineage_edge`` with ``edges`` (reconstruct is source of truth).""" + missing_point_ids = { + post_id + for edge in edges + for post_id in (edge.parent_id, edge.child_id) + if post_id not in points_by_post_id + } + if missing_point_ids: + raise ValueError( + "missing observed interval point for post ids: " + + ", ".join(sorted(missing_point_ids)) + ) await conn.execute("delete from post_lineage_edge") for edge in edges: + relation_code = interval_relation_code_for_edge( + points_by_post_id[edge.parent_id], points_by_post_id[edge.child_id] + ) await conn.execute( - "insert into post_lineage_edge (parent_post_id, child_post_id, fused_score) " - "values ($1::uuid, $2::uuid, $3)", + "insert into post_lineage_edge " + "(parent_post_id, child_post_id, fused_score, interval_relation_code) " + "values ($1::uuid, $2::uuid, $3, $4)", edge.parent_id, edge.child_id, edge.fused_score, + relation_code, ) @@ -232,10 +268,21 @@ async def rebuild_lineage(conn: asyncpg.Connection) -> list[Edge]: if weights is None: raise ChannelWeightsNotEstimated(active_channels) edges = lineage_edge_specs(records_from_source_posts(rows), weights=weights) - await persist_lineage_edges(conn, edges) + await persist_lineage_edges(conn, edges, {str(row["post_id"]): row for row in rows}) return edges +def _interval_payload(row: Mapping[str, Any]) -> dict[str, Any]: + code = row.get("interval_relation_code") + if not code: + return {} + label = row.get("interval_relation_label") or INTERVAL_RELATION_LABELS.get(str(code)) + payload = {"interval_relation_code": str(code)} + if label: + payload["interval_relation_label"] = str(label) + return payload + + async def visible_lineage_graph( conn: asyncpg.Connection, can_see_post, @@ -256,7 +303,8 @@ async def visible_lineage_graph( ) visible_all = [row for row in posts if can_see_post(row)] edge_rows = await conn.fetch( - "select parent_post_id, child_post_id, fused_score from post_lineage_edge" + "select parent_post_id, child_post_id, fused_score, " + "interval_relation_code from post_lineage_edge" ) if focus_post_id is None: @@ -320,12 +368,45 @@ async def visible_lineage_graph( "source": str(row["parent_post_id"]), "target": str(row["child_post_id"]), "fused_score": float(row["fused_score"]), + **_interval_payload(row), } for row in visible_edges ] return {"nodes": nodes, "edges": edges, "truncated": truncated} +async def interval_relations_for_post( + conn: asyncpg.Connection, post_id: str +) -> dict[str, dict[str, Any]]: + """Allen labels on direct reconstructed neighbors of ``post_id``.""" + rows = await conn.fetch( + "select parent_post_id, child_post_id, interval_relation_code " + "from post_lineage_edge " + "where parent_post_id = $1::uuid or child_post_id = $1::uuid", + post_id, + ) + current = str(post_id) + relations: dict[str, dict[str, Any]] = {} + for row in rows: + parent_id = str(row["parent_post_id"]) + child_id = str(row["child_post_id"]) + other_id = child_id if parent_id == current else parent_id + current_is_parent = parent_id == current + stored = _interval_payload(row) + code = stored.get("interval_relation_code") + if not code: + continue + oriented = interval_relation_from_current(str(code), current_is_parent) + relations[other_id] = { + "interval_relation_code": oriented, + "interval_relation_label": INTERVAL_RELATION_LABELS.get( + oriented, stored.get("interval_relation_label") + ), + "interval_is_parent": current_is_parent, + } + return relations + + async def lineage_graphs_for_posts( conn: asyncpg.Connection, can_see_post, diff --git a/backend/app/main.py b/backend/app/main.py index 572d93ca1..f12ccbaba 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -171,6 +171,7 @@ ) from backend.app.lineage_ingestion import ( ChannelWeightsNotEstimated, + interval_relations_for_post, rebuild_lineage, visible_lineage_graph, ) @@ -2297,22 +2298,27 @@ async def read_post_lineage( list(candidate_ids), ) rows = {str(row["post_id"]): row for row in fetched} - - def _visible_summaries(ids: frozenset[str]) -> list[dict[str, Any]]: - return [ - { + direct_intervals = await interval_relations_for_post(conn, post_id) + + def _visible_summaries(ids: frozenset[str], with_intervals: bool = False) -> list[dict[str, Any]]: + summaries = [] + for post_id_ in ids: + if post_id_ not in rows or not _can_see_post(account, rows[post_id_]): + continue + summary = { "post_id": post_id_, "post_title": rows[post_id_]["post_title"], "post_body_excerpt": rows[post_id_].get("post_body_excerpt"), "post_body_truncated": rows[post_id_].get("post_body_truncated", False), } - for post_id_ in ids - if post_id_ in rows and _can_see_post(account, rows[post_id_]) - ] + if with_intervals: + summary.update(direct_intervals.get(post_id_, {})) + summaries.append(summary) + return summaries return { "post_id": post_id, - "direct": _visible_summaries(linked.direct), + "direct": _visible_summaries(linked.direct, with_intervals=True), "indirect": _visible_summaries(linked.indirect), } diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 492b82175..79db925a0 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -138,6 +138,11 @@ / "migrations" / "0135_lineage_channel_weight.sql" ) +_INTERVAL_RELATION_MIGRATION = ( + Path(__file__).resolve().parents[2] + / "migrations" + / "0140_post_lineage_interval_relation.sql" +) _CHANNEL_WEIGHT_UNION_MIGRATION = ( Path(__file__).resolve().parents[2] / "migrations" @@ -306,6 +311,7 @@ def seeded_db(demo_analyst_token): cur.execute(_TOPIC_LINEAGE_RESULT_MIGRATION.read_text()) cur.execute(_TOPIC_LINEAGE_VALIDATE_MIGRATION.read_text()) cur.execute(_CHANNEL_WEIGHT_MIGRATION.read_text()) + cur.execute(_INTERVAL_RELATION_MIGRATION.read_text()) cur.execute(_CHANNEL_WEIGHT_UNION_MIGRATION.read_text()) cur.execute(_PAIR_JUDGMENT_MIGRATION.read_text()) # Product reconstruction fails closed without an ACTIVATED @@ -4311,6 +4317,94 @@ def test_rebuild_lineage_recovers_the_a100_fork(client, demo_analyst_token, seed assert "Delivery schedule question raised" in direct_titles +def test_rebuild_lineage_ignores_mutable_ticket_dates( + client, demo_analyst_token, seeded_db +) -> None: + """Manual ticket dates do not replace observed post chronology.""" + from scripts.seed_demo_data import ( + _seed_fixture_tickets, + insert_fixture_source_posts, + ) + + admin_conn = psycopg2.connect(seeded_db["dsn"]) + admin_conn.autocommit = True + try: + with admin_conn.cursor() as cur: + cur.execute( + "insert into common_lookup_value (lookup_category, lookup_code, lookup_label) " + "values ('permission', 'post_admin', 'Administer posts'), " + "('voc_type', 'vom', 'Voice of Market') " + "on conflict (lookup_code) do nothing" + ) + cur.execute("select access_role_id from account_role_assignment limit 1") + role_id = cur.fetchone()[0] + cur.execute( + "insert into role_permission (access_role_id, permission_code) values (%s, 'post_admin') " + "on conflict do nothing", + (role_id,), + ) + cur.execute( + "insert into process_unit (corporate_entity_id, process_unit_code, process_unit_name) " + "select corporate_entity_id, 'TEST-PU-INTERVAL', 'Interval thread' " + "from source_post where post_id = %s returning process_unit_id", + (seeded_db["own_private_post_id"],), + ) + process_unit_id = cur.fetchone()[0] + cur.execute( + "select author_account_id, corporate_entity_id from source_post where post_id = %s", + (seeded_db["own_private_post_id"],), + ) + author_id, corp_id = cur.fetchone() + insert_fixture_source_posts(cur, author_id, corp_id, process_unit_id) + _seed_fixture_tickets(cur) + finally: + admin_conn.close() + + rebuild = client.post("/api/lineage/rebuild", headers={"Authorization": f"Bearer {demo_analyst_token}"}) + assert rebuild.status_code == 200, rebuild.text + + graph = client.get("/api/lineage", headers={"Authorization": f"Bearer {demo_analyst_token}"}) + assert graph.status_code == 200 + body = graph.json() + nodes = {node["label"]: node for node in body["nodes"]} + fork = nodes["Pricing renegotiation follow-up"] + quote = nodes["Pricing renegotiation: revised quote sent"] + delivery = nodes["Delivery schedule question raised"] + quote_edge = next( + edge + for edge in body["edges"] + if edge["source"] == fork["id"] and edge["target"] == quote["id"] + ) + delivery_edge = next( + edge + for edge in body["edges"] + if edge["source"] == fork["id"] and edge["target"] == delivery["id"] + ) + assert quote_edge["interval_relation_code"] == "interval_before" + assert quote_edge["interval_relation_label"] == "Before" + assert delivery_edge["interval_relation_code"] == "interval_before" + assert delivery_edge["interval_relation_label"] == "Before" + + per_post = client.get( + f"/api/posts/{fork['id']}/lineage", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert per_post.status_code == 200 + by_title = {post["post_title"]: post for post in per_post.json()["direct"]} + assert by_title["Pricing renegotiation: revised quote sent"]["interval_relation_code"] == "interval_before" + assert by_title["Delivery schedule question raised"]["interval_relation_code"] == "interval_before" + + from_quote = client.get( + f"/api/posts/{quote['id']}/lineage", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert from_quote.status_code == 200 + quote_direct = {post["post_id"]: post for post in from_quote.json()["direct"]} + assert quote_direct[fork["id"]]["interval_relation_code"] == "interval_after" + assert quote_direct[fork["id"]]["interval_relation_label"] == "After" + assert quote_direct[fork["id"]]["interval_is_parent"] is False + + def test_lineage_graph_hides_other_corp_private_posts(client, demo_analyst_token, seeded_db) -> None: response = client.get("/api/lineage", headers={"Authorization": f"Bearer {demo_analyst_token}"}) assert response.status_code == 200 @@ -4510,6 +4604,22 @@ def test_post_activity_is_empty_before_any_mutation(client, demo_analyst_token, assert response.json()["events"] == [] +def test_post_activity_requires_post_read(client, demo_analyst_token, seeded_db) -> None: + admin_conn = psycopg2.connect(seeded_db["dsn"]) + admin_conn.autocommit = True + try: + with admin_conn.cursor() as cur: + cur.execute("delete from role_permission where permission_code = 'post_read'") + finally: + admin_conn.close() + + response = client.get( + f"/api/posts/{seeded_db['own_private_post_id']}/activity", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert response.status_code == 403 + + def test_ticket_mutations_publish_real_events_to_the_activity_feed( client, demo_analyst_token, seeded_db ) -> None: diff --git a/docs/adr/0161-event-lineage-interval-relation.md b/docs/adr/0161-event-lineage-interval-relation.md new file mode 100644 index 000000000..1e1e40dfb --- /dev/null +++ b/docs/adr/0161-event-lineage-interval-relation.md @@ -0,0 +1,70 @@ +# ADR 0161 — Name Allen interval relations on Event Lineage edges + +**Decision status:** Accepted +**Date:** 2026-08-23 + +## Context + +Event Lineage already refuses a parent that occurred after its child +(`reconstruct.py` looks only backward). Buyers still see only a fused +score on the edge. They cannot tell whether the child happened after +the parent or on the same day. Allen (1983) partitions every pair of +closed intervals into thirteen relations. CHRONOS (Anagnostopoulos et +al., 2013) uses that +algebra as temporal-consistency evidence, not as a causal claim. + +A post's observed dated window is the UTC calendar day of +`source_post.created_at`, represented as a point interval. An +`issue_ticket.due_date` is mutable and may be entered manually; without an +immutable source reference and derivation state it is not observed Event +Lineage evidence. Ticket-aware interval ends are therefore deferred until a +provenance-bearing interval-evidence contract exists. The product does not +invent a duration or promote the relation to a reconstructed parent. + +## Decision + +Persist `interval_relation_code` on `post_lineage_edge` (3NF, +two-or-more-word `snake_case`) as a `common_lookup_value` code in +the `interval_relation` category. Compute it in +`lineageweave/interval_relation.py` from the two posts' UTC creation-day +points after reconstruct has chosen the parent. Rebuild and seed write the +code in the same transaction as the edge. The thirteen-relation algebra stays +available for future evidence-bearing intervals, but current post projection +uses only observed creation-day points. + +`GET /api/lineage` and `GET /api/posts/{id}/lineage` return the +lookup label next to the fused score. The DAG shows that label as +visible text, not hover-only, and lists each edge as a keyboard +button whose next action is to open the other post. Indirect +Keyman links stay unlabeled -- they are not reconstructed parents. + +Do not store a second fused score. Do not treat During/Contains as +causation. A hidden endpoint still drops the edge. + +## Consequences + +After `make seed`, the A-100 pricing follow-up is **before** the revised quote +and delivery question. Ticket creation, editing, closing, or deletion does not +rewrite that observed chronology. Click a directed relation row to open the +other post. Migration +`0140_post_lineage_interval_relation.sql` upgrades volumes that +already applied `0001`. Point-only backfill uses created days so +existing edges are never left null. + +Renumbered from the originally proposed `0105` (migration) and `0122` +(ADR) after collisions were found with +PR #387 (`migrations/0105_post_lineage_edge_signal.sql`) and PR #383 +(`docs/adr/0122-otel-session-observability.md`). ADR `0160` was then +claimed first by PR #480, so this decision moved to `0161`; see +ContextualWisdomLab/.github#1249. + +## References + +Allen, J. F. (1983). Maintaining knowledge about temporal intervals. +*Communications of the ACM, 26*(11), 832–843. +https://doi.org/10.1145/182.358434 + +Anagnostopoulos, E., Batsakis, S., & Petrakis, E. G. M. (2013). +CHRONOS: A reasoning engine for qualitative temporal information in +OWL. *Procedia Computer Science, 22*, 70–77. +https://doi.org/10.1016/j.procs.2013.09.082 diff --git a/docs/storybook-inventory.md b/docs/storybook-inventory.md index b4c9f3e20..76c3f0c1a 100644 --- a/docs/storybook-inventory.md +++ b/docs/storybook-inventory.md @@ -11,7 +11,7 @@ buyer-facing control you can click before changing product CSS. | `AnalysisRun/CutoffKnownBody` | Read the cutoff-known sentence, then compare it with the live body below. | `--color-accent-border`, `--space-panel-block`, `--radius-panel`, `CutoffKnownBody` | | `Analysis/LineageEntityPicker` | Choose which corp to reconstruct, then click Request a lineage reconstruction. | `--space-control-gap`, `--size-control-min`, `--radius-control`, `LineageEntityPicker` | | `Admin/AdminPanel` | Change the tenant brand name, then verify the saved or failed state before leaving settings. | `--surface`, `--border`, `--space-panel-block`, `AdminPanel` | -| `Lineage/LineageDag` | Open the current branch node; compare empty, grouped/forked, mobile-scroll, ungrouped, and long-title states before changing graph CSS. On narrow viewports, swipe the named viewport or focus it and use arrow keys to inspect the full lineage. | `--surface`, `--border`, `--color-focus-border`, `--size-control-min`, `LineageDag` | +| `Lineage/LineageDag` | Read Before on the A-100 fork, then click the revised-quote row to open that post; compare empty, single-branch, grouped/forked, mobile-scroll, ungrouped, and long-title states before changing graph CSS. On narrow viewports, swipe the named viewport or focus it and use arrow keys to inspect the full lineage. | `--color-accent-background`, `--radius-control`, `--surface`, `--border`, `--color-focus-border`, `--size-control-min`, `LineageDag` | | `Chrome/PopupCloseButton` | Close the evidence panel or post popup. | `--space-close-inset`, `--font-size-close`, `PopupCloseButton` | | `Navigation/WorkspaceNav` | Open 게시판, 고객 마스터, 달력, or Ask Agent. Admin is not a GNB tab. | `--gnb-height`, `--gnb-active-indicator-color`, `WorkspaceNav` | | `Evidence/OntologyExplorer` | Inspect typed people/orgs/posts, then open authorized evidence. Distinct from Event Lineage. | `--color-primary`, `--color-table-border`, `OntologyExplorer` | diff --git a/frontend/package.json b/frontend/package.json index 5e9cc95d5..2a95c3a22 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "2.15.0", + "version": "2.15.1", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.css b/frontend/src/App.css index 4ea088b09..fbbaf1da6 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -597,6 +597,58 @@ stroke: var(--text-h); } +.lineage-dag-interval { + font-size: 10px; + font-weight: 600; + fill: var(--color-primary); + pointer-events: none; +} + +.lineage-interval-list { + list-style: none; + padding: 0.5rem 0 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.lineage-interval-button { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + text-align: left; + border: 1px solid var(--color-border); + border-radius: var(--radius-control); + background: var(--surface); + color: var(--text-h); + padding: 0.35rem 0.6rem; + cursor: pointer; + font: inherit; +} + +.lineage-interval-button:hover, +.lineage-interval-button:focus { + border-color: var(--color-focus-border); + outline: 2px solid var(--color-focus-ring); +} + +.lineage-interval-code, +.related-post-interval { + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.02em; + padding: 0.1rem 0.45rem; + border-radius: 999px; + background: var(--color-accent-background); + color: var(--color-primary); +} + +.related-post-interval { + margin-left: 0.35rem; +} + .keyman-list { list-style: none; padding: 0; diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index f1f31326a..48e3697c4 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -1624,7 +1624,15 @@ describe("App, authenticated", () => { return Promise.resolve( jsonResponse({ post_id: "post-1", - direct: [], + direct: [ + { + post_id: "rec-003", + post_title: "Pricing renegotiation: revised quote sent", + interval_relation_code: "interval_contains", + interval_relation_label: "Contains", + interval_is_parent: true, + }, + ], indirect: [{ post_id: "post-2", post_title: "Linked post" }], }), ); @@ -2346,6 +2354,8 @@ describe("App, authenticated", () => { ); expect(relatedPosts).not.toBeNull(); expect(within(relatedPosts as HTMLElement).getByText("Indirect relation")).toBeInTheDocument(); + expect(within(relatedPosts as HTMLElement).getByText("Direct relation")).toBeInTheDocument(); + expect(within(relatedPosts as HTMLElement).getByText("Contains")).toBeInTheDocument(); expect(relatedPosts).toHaveTextContent("Linked post"); // The Event Lineage DAG belongs to the opened post, not the list surface. expect(screen.getAllByLabelText("A-100 lineage")).toHaveLength(1); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 55f6d79d6..1ba1e181b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -552,6 +552,9 @@ function RelatedPostsSection({ const cardContent = ( <> {t(kind)} + {kind === "Direct relation" && post.interval_relation_label ? ( + {t(post.interval_relation_label)} + ) : null} {post.post_title} diff --git a/frontend/src/LineageDag.stories.tsx b/frontend/src/LineageDag.stories.tsx index f41fcbd1d..eba3cd29b 100644 --- a/frontend/src/LineageDag.stories.tsx +++ b/frontend/src/LineageDag.stories.tsx @@ -2,11 +2,73 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { LineageDag } from "./LineageDag"; import type { LineageGraph } from "./api"; +const a100Graph: LineageGraph = { + nodes: [ + { + id: "rec-001", + group: "A-100", + label: "Initial site visit and project scope discussion", + occurred_at: "2026-01-01T00:00:00", + is_root: true, + is_branch_point: false, + }, + { + id: "rec-002", + group: "A-100", + label: "Pricing renegotiation follow-up", + occurred_at: "2026-01-06T00:00:00", + is_root: false, + is_branch_point: true, + }, + { + id: "rec-003", + group: "A-100", + label: "Pricing renegotiation: revised quote sent", + occurred_at: "2026-01-10T00:00:00", + is_root: false, + is_branch_point: false, + }, + { + id: "rec-004", + group: "A-100", + label: "Delivery schedule question raised", + occurred_at: "2026-01-07T00:00:00", + is_root: false, + is_branch_point: false, + }, + ], + edges: [ + { + source: "rec-001", + target: "rec-002", + fused_score: 0.8, + interval_relation_code: "interval_before", + interval_relation_label: "Before", + }, + { + source: "rec-002", + target: "rec-003", + fused_score: 0.9, + interval_relation_code: "interval_contains", + interval_relation_label: "Contains", + }, + { + source: "rec-002", + target: "rec-004", + fused_score: 0.85, + interval_relation_code: "interval_overlaps", + interval_relation_label: "Overlaps", + }, + ], +}; + const meta = { title: "Lineage/LineageDag", component: LineageDag, args: { + graph: a100Graph, onSelectPost: () => undefined, + currentPostId: "rec-002", }, } satisfies Meta; @@ -14,6 +76,8 @@ export default meta; type Story = StoryObj; +export const ContainsAndOverlaps: Story = {}; + // Edge case: no reconstructed lineage yet -- must not render an empty SVG. export const Empty: Story = { args: { @@ -34,6 +98,7 @@ export const SingleBranch: Story = { { source: "a2", target: "a3", fused_score: 0.91 }, ], } satisfies LineageGraph, + currentPostId: undefined, }, }; @@ -98,6 +163,7 @@ export const UngroupedNode: Story = { nodes: [{ id: "u1", group: "", label: "Standalone note with no thread yet", occurred_at: "2026-01-01T00:00:00Z", is_root: true, is_branch_point: false }], edges: [], } satisfies LineageGraph, + currentPostId: undefined, }, }; @@ -118,5 +184,6 @@ export const LongNodeLabel: Story = { ], edges: [], } satisfies LineageGraph, + currentPostId: undefined, }, }; diff --git a/frontend/src/LineageDag.test.tsx b/frontend/src/LineageDag.test.tsx index c53646d3f..f18d3395a 100644 --- a/frontend/src/LineageDag.test.tsx +++ b/frontend/src/LineageDag.test.tsx @@ -4,6 +4,51 @@ import { describe, expect, it, vi } from "vitest"; import { LineageDag } from "./LineageDag"; import type { LineageGraph } from "./api"; +const a100Graph: LineageGraph = { + nodes: [ + { + id: "rec-002", + group: "A-100", + label: "Pricing renegotiation follow-up", + occurred_at: "2026-01-06T00:00:00", + is_root: false, + is_branch_point: true, + }, + { + id: "rec-003", + group: "A-100", + label: "Pricing renegotiation: revised quote sent", + occurred_at: "2026-01-10T00:00:00", + is_root: false, + is_branch_point: false, + }, + { + id: "rec-004", + group: "A-100", + label: "Delivery schedule question raised", + occurred_at: "2026-01-07T00:00:00", + is_root: false, + is_branch_point: false, + }, + ], + edges: [ + { + source: "rec-002", + target: "rec-003", + fused_score: 0.9, + interval_relation_code: "interval_contains", + interval_relation_label: "Contains", + }, + { + source: "rec-002", + target: "rec-004", + fused_score: 0.85, + interval_relation_code: "interval_overlaps", + interval_relation_label: "Overlaps", + }, + ], +}; + const graph = { nodes: [ { @@ -27,6 +72,40 @@ const graph = { }; describe("LineageDag", () => { + it("shows Contains and Overlaps as visible text, not hover-only", () => { + const { container } = render( + undefined} currentPostId="rec-002" />, + ); + expect(screen.getAllByText("Contains").length).toBeGreaterThan(0); + expect(screen.getAllByText("Overlaps").length).toBeGreaterThan(0); + expect(screen.getByRole("list", { name: "Interval relations" })).toBeInTheDocument(); + expect(container.querySelector("path title")).toHaveTextContent( + "Pricing renegotiation: revised quote sent follows Pricing renegotiation follow-up", + ); + }); + + it("opens the revised quote from the Contains keyboard row", async () => { + const onSelectPost = vi.fn(); + render(); + await userEvent.click( + screen.getByRole("button", { + name: "Pricing renegotiation follow-up relates to Pricing renegotiation: revised quote sent as Contains; open Pricing renegotiation: revised quote sent", + }), + ); + expect(onSelectPost).toHaveBeenCalledWith("rec-003"); + }); + + it("keeps the stored parent-to-child direction when the child is current", async () => { + const onSelectPost = vi.fn(); + render(); + await userEvent.click( + screen.getByRole("button", { + name: "Pricing renegotiation follow-up relates to Pricing renegotiation: revised quote sent as Contains; open Pricing renegotiation follow-up", + }), + ); + expect(onSelectPost).toHaveBeenCalledWith("rec-002"); + }); + it("gives every node mark a 24x24px-minimum transparent hit target ahead of the visible mark", () => { render( undefined} />); const button = screen.getByRole("button", { name: "Open post: Initial site visit and project scope discussion" }); diff --git a/frontend/src/LineageDag.tsx b/frontend/src/LineageDag.tsx index 6a1c6cd7d..eb5752c57 100644 --- a/frontend/src/LineageDag.tsx +++ b/frontend/src/LineageDag.tsx @@ -1,4 +1,4 @@ -import type { LineageGraph } from "./api"; +import type { LineageGraph, LineageGraphEdge } from "./api"; import { t, tf } from "./i18n"; import { layoutLineageDag } from "./lineageLayout"; import "./LineageDag.css"; @@ -7,6 +7,17 @@ function truncateLabel(label: string): string { return label.length > 34 ? `${label.slice(0, 33)}…` : label; } +function intervalLabel(edge: LineageGraphEdge): string | undefined { + const label = edge.interval_relation_label?.trim(); + return label || undefined; +} + +function otherPostId(edge: LineageGraphEdge, currentPostId?: string): string { + if (currentPostId === edge.source) return edge.target; + if (currentPostId === edge.target) return edge.source; + return edge.target; +} + // Mirrors --size-control-min (24px, styles/tokens.css). One SVG user unit is // ~1px here (see lineageLayout ROW_H/COL_W/PAD), so this radius gives the // visible 7px node mark a 24x24px minimum hit area without CSS scale-up. @@ -31,6 +42,7 @@ export function LineageDag({
{groups.map((group) => { const byId = Object.fromEntries(group.nodes.map((node) => [node.id, node])); + const labeledEdges = group.edges.filter((edge) => intervalLabel(edge) && byId[edge.source] && byId[edge.target]); const hasBranchPoint = group.nodes.some((node) => node.is_branch_point); return (
@@ -68,20 +80,40 @@ export function LineageDag({ const from = byId[edge.source]; const to = byId[edge.target]; const midX = (from.x + to.x) / 2; + const midY = (from.y + to.y) / 2; + const relation = intervalLabel(edge); return ( - - - {tf("{from} follows {to} ({score})", { - from: from.label, - to: to.label, - score: edge.fused_score.toFixed(2), - })} - - + + + + {relation + ? tf("{from} follows {to} ({score}) — {relation}", { + from: to.label, + to: from.label, + score: edge.fused_score.toFixed(2), + relation: t(relation), + }) + : tf("{from} follows {to} ({score})", { + from: to.label, + to: from.label, + score: edge.fused_score.toFixed(2), + })} + + + {relation ? ( + + {t(relation)} + + ) : null} + ); })} {group.nodes.map((node) => { @@ -125,6 +157,36 @@ export function LineageDag({ })}
+ {labeledEdges.length > 0 ? ( +
    + {labeledEdges.map((edge) => { + const from = byId[edge.source]; + const to = byId[edge.target]; + const openId = otherPostId(edge, currentPostId); + const openNode = byId[openId]; + const relation = intervalLabel(edge); + if (!from || !to || !openNode || !relation) return null; + return ( +
  • + +
  • + ); + })} +
+ ) : null} ); })} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 5f06c2da0..0ebb464bd 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -258,6 +258,9 @@ export interface LinkedPostRef { post_title: string; post_body_excerpt?: string | null; post_body_truncated?: boolean; + interval_relation_code?: string; + interval_relation_label?: string; + interval_is_parent?: boolean; } export interface PostLineage { @@ -431,6 +434,8 @@ export interface LineageGraphEdge { source: string; target: string; fused_score: number; + interval_relation_code?: string; + interval_relation_label?: string; } export interface LineageGraph { diff --git a/frontend/src/i18n.test.ts b/frontend/src/i18n.test.ts index 034ca312d..69b1994e3 100644 --- a/frontend/src/i18n.test.ts +++ b/frontend/src/i18n.test.ts @@ -60,6 +60,9 @@ describe("i18n", () => { "Read leftover map rank {rank}, observed Y {observed}, and expected E {expected} after IRT main effects, then open this post.", "Leftover map rank 0 means no leftover structure after IRT main effects. Read observed Y {observed} and expected E {expected}, then open this post.", "Showing the first {shown} of {total} posts known at this cutoff.", + "Contains", + "Overlaps", + "Interval relations", "Inspect ontology neighborhood", "Ontology neighborhood", "This is an ontology neighborhood, not Event Lineage.", @@ -135,6 +138,23 @@ describe("i18n", () => { expect(tf("{post} is current in Event Lineage. Read Keyman and evaluation next.", { post: "DEMO" })).toBe(expected); }); + it.each([ + ["ko", "부모", "자식", "부모의 자식에 대한 관계: 포함; 부모 열기"], + ["zh", "父", "子", "父 与 子 的关系:包含;打开 父"], + ["ja", "親", "子", "親から子への関係: 含む; 親を開く"], + ["vi", "cha", "con", "Quan hệ từ cha đến con: Chứa; mở cha"], + ] as const)("formats directed interval evidence in %s", (locale, from, to, expected) => { + setLocale(locale); + expect( + tf("{from} relates to {to} as {relation}; open {label}", { + from, + to, + relation: t("Contains"), + label: from, + }), + ).toBe(expected); + }); + it.each([ ["ko", "영역 위치"], ["zh", "区域位置"], diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index 21907b9ab..53720d549 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -328,6 +328,23 @@ const TRANSLATIONS: Partial>> = { "Open evidence: {title}": "근거 열기: {title}", "Open post: {label}": "글 열기: {label}", "{from} follows {to} ({score})": "{from}이(가) {to}을(를) 따름 ({score})", + "{from} follows {to} ({score}) — {relation}": "{from}이(가) {to}을(를) 따름 ({score}) — {relation}", + "Interval relations": "시간 구간 관계", + "{from} relates to {to} as {relation}; open {label}": + "{from}의 {to}에 대한 관계: {relation}; {label} 열기", + Before: "이전", + After: "이후", + Meets: "바로 이음", + "Met by": "바로 이어짐", + Overlaps: "겹침", + "Overlapped by": "겹침됨", + Starts: "함께 시작", + "Started by": "시작 포함", + During: "동안", + Contains: "포함", + Finishes: "함께 끝", + "Finished by": "끝 포함", + Equals: "같은 구간", "{label} — {date}": "{label} — {date}", "Corporate entity to reconstruct": "재구성할 법인", "Next action": "다음 작업", @@ -786,6 +803,23 @@ const TRANSLATIONS: Partial>> = { "Open evidence: {title}": "打开证据:{title}", "Open post: {label}": "打开文章:{label}", "{from} follows {to} ({score})": "{from} 接续 {to}({score})", + "{from} follows {to} ({score}) — {relation}": "{from} 接续 {to}({score})— {relation}", + "Interval relations": "时间区间关系", + "{from} relates to {to} as {relation}; open {label}": + "{from} 与 {to} 的关系:{relation};打开 {label}", + Before: "早于", + After: "晚于", + Meets: "相接", + "Met by": "被相接", + Overlaps: "重叠", + "Overlapped by": "被重叠", + Starts: "同时开始", + "Started by": "开始包含", + During: "期间", + Contains: "包含", + Finishes: "同时结束", + "Finished by": "结束包含", + Equals: "相同区间", "{label} — {date}": "{label} — {date}", "Corporate entity to reconstruct": "要重建的法人实体", "Next action": "下一步操作", @@ -1243,6 +1277,23 @@ const TRANSLATIONS: Partial>> = { "{group} lineage": "{group}の系譜", "Open post: {label}": "投稿を開く: {label}", "{from} follows {to} ({score})": "{from}は{to}に続く({score})", + "{from} follows {to} ({score}) — {relation}": "{from}は{to}に続く({score})— {relation}", + "Interval relations": "時間区間の関係", + "{from} relates to {to} as {relation}; open {label}": + "{from}から{to}への関係: {relation}; {label}を開く", + Before: "前", + After: "後", + Meets: "直後", + "Met by": "直前", + Overlaps: "重なる", + "Overlapped by": "重ねられる", + Starts: "同時開始", + "Started by": "開始を含む", + During: "期間内", + Contains: "含む", + Finishes: "同時終了", + "Finished by": "終了を含む", + Equals: "同じ区間", "{label} — {date}": "{label} — {date}", "Corporate entity to reconstruct": "再構成する法人", "Next action": "次の操作", @@ -1701,6 +1752,23 @@ const TRANSLATIONS: Partial>> = { "{group} lineage": "Dòng sự kiện {group}", "Open post: {label}": "Mở bài viết: {label}", "{from} follows {to} ({score})": "{from} tiếp nối {to} ({score})", + "{from} follows {to} ({score}) — {relation}": "{from} tiếp nối {to} ({score}) — {relation}", + "Interval relations": "Quan hệ khoảng thời gian", + "{from} relates to {to} as {relation}; open {label}": + "Quan hệ từ {from} đến {to}: {relation}; mở {label}", + Before: "Trước", + After: "Sau", + Meets: "Kề ngay", + "Met by": "Được kề ngay", + Overlaps: "Chồng lấp", + "Overlapped by": "Bị chồng lấp", + Starts: "Bắt đầu cùng", + "Started by": "Bắt đầu chứa", + During: "Trong khoảng", + Contains: "Chứa", + Finishes: "Kết thúc cùng", + "Finished by": "Kết thúc chứa", + Equals: "Cùng khoảng", "{label} — {date}": "{label} — {date}", "Corporate entity to reconstruct": "Pháp nhân cần tái dựng", "Next action": "Thao tác tiếp theo", diff --git a/lineageweave/interval_relation.py b/lineageweave/interval_relation.py new file mode 100644 index 000000000..d35317c08 --- /dev/null +++ b/lineageweave/interval_relation.py @@ -0,0 +1,148 @@ +"""Allen (1983) closed interval relations for Event Lineage edges. + +Reconstruct already refuses a parent that occurred after its child. +This module names the Allen relation between two dated windows so a buyer can +see *how* they relate in time, not only that a fused score attached them. A +post is a degenerate point interval on its observed ``created_at`` day. Mutable +ticket dates are not Event Lineage evidence. + +Does not invent a theta, a fused score, or a lineage parent. +""" + +from __future__ import annotations + +from datetime import date, datetime, timezone + +INTERVAL_BEFORE = "interval_before" +INTERVAL_AFTER = "interval_after" +INTERVAL_MEETS = "interval_meets" +INTERVAL_MET_BY = "interval_met_by" +INTERVAL_OVERLAPS = "interval_overlaps" +INTERVAL_OVERLAPPED_BY = "interval_overlapped_by" +INTERVAL_STARTS = "interval_starts" +INTERVAL_STARTED_BY = "interval_started_by" +INTERVAL_DURING = "interval_during" +INTERVAL_CONTAINS = "interval_contains" +INTERVAL_FINISHES = "interval_finishes" +INTERVAL_FINISHED_BY = "interval_finished_by" +INTERVAL_EQUALS = "interval_equals" + +INTERVAL_RELATION_CODES: tuple[str, ...] = ( + INTERVAL_BEFORE, + INTERVAL_AFTER, + INTERVAL_MEETS, + INTERVAL_MET_BY, + INTERVAL_OVERLAPS, + INTERVAL_OVERLAPPED_BY, + INTERVAL_STARTS, + INTERVAL_STARTED_BY, + INTERVAL_DURING, + INTERVAL_CONTAINS, + INTERVAL_FINISHES, + INTERVAL_FINISHED_BY, + INTERVAL_EQUALS, +) + +INTERVAL_RELATION_LABELS: dict[str, str] = { + INTERVAL_BEFORE: "Before", + INTERVAL_AFTER: "After", + INTERVAL_MEETS: "Meets", + INTERVAL_MET_BY: "Met by", + INTERVAL_OVERLAPS: "Overlaps", + INTERVAL_OVERLAPPED_BY: "Overlapped by", + INTERVAL_STARTS: "Starts", + INTERVAL_STARTED_BY: "Started by", + INTERVAL_DURING: "During", + INTERVAL_CONTAINS: "Contains", + INTERVAL_FINISHES: "Finishes", + INTERVAL_FINISHED_BY: "Finished by", + INTERVAL_EQUALS: "Equals", +} + +INTERVAL_RELATION_INVERSE: dict[str, str] = { + INTERVAL_BEFORE: INTERVAL_AFTER, + INTERVAL_AFTER: INTERVAL_BEFORE, + INTERVAL_MEETS: INTERVAL_MET_BY, + INTERVAL_MET_BY: INTERVAL_MEETS, + INTERVAL_OVERLAPS: INTERVAL_OVERLAPPED_BY, + INTERVAL_OVERLAPPED_BY: INTERVAL_OVERLAPS, + INTERVAL_STARTS: INTERVAL_STARTED_BY, + INTERVAL_STARTED_BY: INTERVAL_STARTS, + INTERVAL_DURING: INTERVAL_CONTAINS, + INTERVAL_CONTAINS: INTERVAL_DURING, + INTERVAL_FINISHES: INTERVAL_FINISHED_BY, + INTERVAL_FINISHED_BY: INTERVAL_FINISHES, + INTERVAL_EQUALS: INTERVAL_EQUALS, +} + +ClosedInterval = tuple[date, date] + + +def calendar_day(value: datetime | date) -> date: + """Normalize a timestamptz to its UTC day; preserve a calendar date.""" + if isinstance(value, datetime): + if value.utcoffset() is not None: + value = value.astimezone(timezone.utc) + return value.date() + return value + + +def interval_from_post(created_at: datetime | date) -> ClosedInterval: + """Return the post's observed UTC creation day as a point interval.""" + start = calendar_day(created_at) + return start, start + + +def allen_interval_relation(parent: ClosedInterval, child: ClosedInterval) -> str: + """Return the Allen relation of ``parent`` toward ``child``. + + Both intervals are closed. The thirteen relations partition every + pair of well-formed intervals (Allen, 1983). + """ + parent_start, parent_end = parent + child_start, child_end = child + if parent_start > parent_end or child_start > child_end: + raise ValueError(f"inverted interval: parent={parent} child={child}") + if parent_start == child_start and parent_end == child_end: + return INTERVAL_EQUALS + if parent_end < child_start: + return INTERVAL_BEFORE + if child_end < parent_start: + return INTERVAL_AFTER + if parent_end == child_start: + return INTERVAL_MEETS + if child_end == parent_start: + return INTERVAL_MET_BY + if parent_start < child_start and parent_end < child_end and child_start < parent_end: + return INTERVAL_OVERLAPS + if child_start < parent_start and child_end < parent_end and parent_start < child_end: + return INTERVAL_OVERLAPPED_BY + if parent_start == child_start and parent_end < child_end: + return INTERVAL_STARTS + if parent_start == child_start and child_end < parent_end: + return INTERVAL_STARTED_BY + if child_start < parent_start and parent_end < child_end: + return INTERVAL_DURING + if parent_start < child_start and child_end < parent_end: + return INTERVAL_CONTAINS + if parent_end == child_end and parent_start > child_start: + return INTERVAL_FINISHES + if parent_end == child_end and parent_start < child_start: + return INTERVAL_FINISHED_BY + # The thirteen branches above exhaust every pair of valid closed intervals. + raise ValueError( # pragma: no cover - defensive invariant + f"unclassified intervals parent={parent} child={child}" + ) + + +def interval_relation_from_current(code: str, current_is_parent: bool) -> str: + """Orient a stored parent→child code toward the post the buyer opened. + + The edge stores the parent's relation toward the child. Opening the + child must show the inverse (Contains → During) rather than claiming + the child contains its parent. Unknown codes stay as stored -- this + does not invent a thirteenth-plus relation. + """ + if current_is_parent: + return code + return INTERVAL_RELATION_INVERSE.get(code, code) diff --git a/migrations/0140_post_lineage_interval_relation.sql b/migrations/0140_post_lineage_interval_relation.sql new file mode 100644 index 000000000..6bcc06d98 --- /dev/null +++ b/migrations/0140_post_lineage_interval_relation.sql @@ -0,0 +1,68 @@ +-- ADR 0161: persist Allen (1983) interval relation on Event Lineage edges. +-- Lookups first so the FK can land on existing volumes that already ran 0001. + +insert into common_lookup_value (lookup_category, lookup_code, lookup_label, display_order) values + ('interval_relation', 'interval_before', 'Before', 0), + ('interval_relation', 'interval_after', 'After', 1), + ('interval_relation', 'interval_meets', 'Meets', 2), + ('interval_relation', 'interval_met_by', 'Met by', 3), + ('interval_relation', 'interval_overlaps', 'Overlaps', 4), + ('interval_relation', 'interval_overlapped_by', 'Overlapped by', 5), + ('interval_relation', 'interval_starts', 'Starts', 6), + ('interval_relation', 'interval_started_by', 'Started by', 7), + ('interval_relation', 'interval_during', 'During', 8), + ('interval_relation', 'interval_contains', 'Contains', 9), + ('interval_relation', 'interval_finishes', 'Finishes', 10), + ('interval_relation', 'interval_finished_by', 'Finished by', 11), + ('interval_relation', 'interval_equals', 'Equals', 12) +on conflict (lookup_code) do nothing; + +alter table post_lineage_edge + add column if not exists interval_relation_code text; + +do $$ +begin + if not exists ( + select 1 from pg_constraint + where conrelid = 'post_lineage_edge'::regclass + and conname = 'post_lineage_edge_interval_relation_code_fkey' + ) then + alter table post_lineage_edge + add constraint post_lineage_edge_interval_relation_code_fkey + foreign key (interval_relation_code) + references common_lookup_value (lookup_code) + not valid; + end if; +end +$$; + +update post_lineage_edge as edge + set interval_relation_code = case + when (parent_post.created_at at time zone 'UTC')::date < (child_post.created_at at time zone 'UTC')::date then 'interval_before' + when (parent_post.created_at at time zone 'UTC')::date > (child_post.created_at at time zone 'UTC')::date then 'interval_after' + else 'interval_equals' + end + from source_post as parent_post + join source_post as child_post on true + where edge.parent_post_id = parent_post.post_id + and edge.child_post_id = child_post.post_id + and edge.interval_relation_code is null; + +update post_lineage_edge + set interval_relation_code = 'interval_before' + where interval_relation_code is null; + +do $$ +begin + if exists ( + select 1 + from information_schema.columns + where table_name = 'post_lineage_edge' + and column_name = 'interval_relation_code' + and is_nullable = 'YES' + ) then + alter table post_lineage_edge + alter column interval_relation_code set not null; + end if; +end +$$; diff --git a/migrations/0205_validate_post_lineage_interval_relation.sql b/migrations/0205_validate_post_lineage_interval_relation.sql new file mode 100644 index 000000000..9beb11443 --- /dev/null +++ b/migrations/0205_validate_post_lineage_interval_relation.sql @@ -0,0 +1,3 @@ +-- Validate the ADR 0161 foreign key separately from its short NOT VALID installation. +alter table post_lineage_edge + validate constraint post_lineage_edge_interval_relation_code_fkey; diff --git a/migrations/rollback/0140_post_lineage_interval_relation.sql b/migrations/rollback/0140_post_lineage_interval_relation.sql new file mode 100644 index 000000000..afe1b3a3e --- /dev/null +++ b/migrations/rollback/0140_post_lineage_interval_relation.sql @@ -0,0 +1,5 @@ +alter table post_lineage_edge + drop column if exists interval_relation_code; + +delete from common_lookup_value + where lookup_category = 'interval_relation'; diff --git a/pyproject.toml b/pyproject.toml index ba2d25af9..6461610b9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "2.15.0" +version = "2.15.1" 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 931840704..ca2b51024 100644 --- a/scripts/seed_demo_data.py +++ b/scripts/seed_demo_data.py @@ -49,8 +49,8 @@ DEMO_TOPIC_LINEAGE_IDEMPOTENCY_KEY = "demo-topic-lineage-seed-2026-w02" DEMO_REPORT_IDEMPOTENCY_KEY = "demo-report-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. +# (post_title, ticket_title, due_date) -- report/calendar fixture tickets. +# Activity seed uses the same titles so Valkey matches. FIXTURE_TICKET_SPECS = ( ( "Pricing renegotiation follow-up", @@ -139,6 +139,7 @@ def seed( cur.execute((migrations / "0131_analysis_run_topic_lineage_kind.sql").read_text()) cur.execute((migrations / "0024_source_post_revision.sql").read_text()) cur.execute((migrations / "0025_role_person_catalog_identity.sql").read_text()) + cur.execute((migrations / "0140_post_lineage_interval_relation.sql").read_text()) cur.execute( """ insert into common_lookup_value (lookup_category, lookup_code, lookup_label, display_order) values @@ -170,7 +171,20 @@ def seed( ('entity_relationship_type', 'rel_vos', 'Voice of Supplier', 5), ('ticket_status', 'open', 'Open', 0), ('ticket_status', 'in_progress', 'In progress', 1), - ('ticket_status', 'closed', 'Closed', 2) + ('ticket_status', 'closed', 'Closed', 2), + ('interval_relation', 'interval_before', 'Before', 0), + ('interval_relation', 'interval_after', 'After', 1), + ('interval_relation', 'interval_meets', 'Meets', 2), + ('interval_relation', 'interval_met_by', 'Met by', 3), + ('interval_relation', 'interval_overlaps', 'Overlaps', 4), + ('interval_relation', 'interval_overlapped_by', 'Overlapped by', 5), + ('interval_relation', 'interval_starts', 'Starts', 6), + ('interval_relation', 'interval_started_by', 'Started by', 7), + ('interval_relation', 'interval_during', 'During', 8), + ('interval_relation', 'interval_contains', 'Contains', 9), + ('interval_relation', 'interval_finishes', 'Finishes', 10), + ('interval_relation', 'interval_finished_by', 'Finished by', 11), + ('interval_relation', 'interval_equals', 'Equals', 12) on conflict (lookup_code) do nothing """ ) @@ -431,6 +445,7 @@ def seed( _seed_fixture_chats(cur) _seed_fixture_evaluations(cur) _seed_fixture_tickets(cur) + _seed_lineage_interval_relations(cur) _seed_fixture_ticket_activity(cur, account_ids["demo.analyst"], valkey_url) _seed_demo_period_report( cur, @@ -624,8 +639,9 @@ def _seed_reconstructed_lineage(cur, author_account_id, corporate_entity_id, pro ) for edge in lineage_edge_specs(persisted, weights=estimate.weights): cur.execute( - "insert into post_lineage_edge (parent_post_id, child_post_id, fused_score) " - "values (%s, %s, %s) on conflict do nothing", + "insert into post_lineage_edge " + "(parent_post_id, child_post_id, fused_score, interval_relation_code) " + "values (%s, %s, %s, 'interval_before') on conflict do nothing", (edge.parent_id, edge.child_id, edge.fused_score), ) @@ -1061,6 +1077,36 @@ def _seed_fixture_tickets(cur) -> None: ) +def _seed_lineage_interval_relations(cur) -> None: + """Name Allen relations from observed post creation-day points (ADR 0161).""" + from lineageweave.interval_relation import ( + allen_interval_relation, + interval_from_post, + ) + + cur.execute( + """ + select edge.parent_post_id, edge.child_post_id, + parent_post.created_at, child_post.created_at + from post_lineage_edge as edge + join source_post as parent_post on parent_post.post_id = edge.parent_post_id + join source_post as child_post on child_post.post_id = edge.child_post_id + """ + ) + rows = list(cur.fetchall()) + for parent_id, child_id, parent_created, child_created in rows: + code = allen_interval_relation( + interval_from_post(parent_created), + interval_from_post(child_created), + ) + cur.execute( + "update post_lineage_edge set interval_relation_code = %s " + "where parent_post_id = %s and child_post_id = %s", + (code, parent_id, child_id), + ) + + + def _seed_fixture_ticket_activity(cur, actor_account_id, valkey_url: str) -> None: """``XADD`` ticket_created onto each seeded ticket's post stream. diff --git a/tests/test_interval_relation.py b/tests/test_interval_relation.py new file mode 100644 index 000000000..124157d82 --- /dev/null +++ b/tests/test_interval_relation.py @@ -0,0 +1,115 @@ +"""Allen interval relations are exhaustive and post points stay observed.""" + +from __future__ import annotations + +from datetime import date, datetime, timedelta, timezone + +import pytest + +from lineageweave.fixtures import sample_records +from lineageweave.interval_relation import ( + INTERVAL_AFTER, + INTERVAL_BEFORE, + INTERVAL_CONTAINS, + INTERVAL_DURING, + INTERVAL_EQUALS, + INTERVAL_FINISHED_BY, + INTERVAL_FINISHES, + INTERVAL_MEETS, + INTERVAL_MET_BY, + INTERVAL_OVERLAPPED_BY, + INTERVAL_OVERLAPS, + INTERVAL_STARTED_BY, + INTERVAL_STARTS, + allen_interval_relation, + interval_from_post, + interval_relation_from_current, +) + + +def _d(month: int, day: int) -> date: + return date(2026, month, day) + + +def test_all_thirteen_allen_relations_are_partitioned() -> None: + cases = ( + ((_d(1, 1), _d(1, 2)), (_d(1, 4), _d(1, 5)), INTERVAL_BEFORE), + ((_d(1, 4), _d(1, 5)), (_d(1, 1), _d(1, 2)), INTERVAL_AFTER), + ((_d(1, 1), _d(1, 3)), (_d(1, 3), _d(1, 5)), INTERVAL_MEETS), + ((_d(1, 3), _d(1, 5)), (_d(1, 1), _d(1, 3)), INTERVAL_MET_BY), + ((_d(1, 1), _d(1, 4)), (_d(1, 3), _d(1, 6)), INTERVAL_OVERLAPS), + ((_d(1, 3), _d(1, 6)), (_d(1, 1), _d(1, 4)), INTERVAL_OVERLAPPED_BY), + ((_d(1, 1), _d(1, 2)), (_d(1, 1), _d(1, 5)), INTERVAL_STARTS), + ((_d(1, 1), _d(1, 5)), (_d(1, 1), _d(1, 2)), INTERVAL_STARTED_BY), + ((_d(1, 3), _d(1, 4)), (_d(1, 1), _d(1, 6)), INTERVAL_DURING), + ((_d(1, 1), _d(1, 6)), (_d(1, 3), _d(1, 4)), INTERVAL_CONTAINS), + ((_d(1, 3), _d(1, 6)), (_d(1, 1), _d(1, 6)), INTERVAL_FINISHES), + ((_d(1, 1), _d(1, 6)), (_d(1, 3), _d(1, 6)), INTERVAL_FINISHED_BY), + ((_d(1, 2), _d(1, 4)), (_d(1, 2), _d(1, 4)), INTERVAL_EQUALS), + ) + for parent, child, expected in cases: + assert allen_interval_relation(parent, child) == expected + + +def test_point_intervals_on_different_days_are_before_not_meets() -> None: + assert ( + allen_interval_relation((_d(1, 5), _d(1, 5)), (_d(1, 6), _d(1, 6))) + == INTERVAL_BEFORE + ) + + +def test_post_creation_day_is_a_point_interval() -> None: + assert interval_from_post(datetime(2026, 1, 6, 15, 0, 0)) == ( + _d(1, 6), + _d(1, 6), + ) + assert interval_from_post(_d(1, 6)) == (_d(1, 6), _d(1, 6)) + + +def test_created_day_is_normalized_to_utc() -> None: + local_midnight = datetime( + 2026, 1, 2, 0, 30, tzinfo=timezone(timedelta(hours=9)) + ) + + assert interval_from_post(local_midnight) == (_d(1, 1), _d(1, 1)) + + +def test_inverted_bounds_fail_closed() -> None: + with pytest.raises(ValueError, match="inverted"): + allen_interval_relation((_d(1, 5), _d(1, 1)), (_d(1, 6), _d(1, 7))) + + +def test_a100_lineage_uses_observed_creation_day_points() -> None: + """Mutable ticket dates do not alter the designed fork's chronology.""" + records = {record.record_id: record for record in sample_records()} + relations = { + (parent_id, child_id): allen_interval_relation( + interval_from_post(records[parent_id].occurred_at), + interval_from_post(records[child_id].occurred_at), + ) + for parent_id, child_id in ( + ("rec-001", "rec-002"), + ("rec-002", "rec-003"), + ("rec-002", "rec-004"), + ) + } + assert relations[("rec-001", "rec-002")] == INTERVAL_BEFORE + assert relations[("rec-002", "rec-003")] == INTERVAL_BEFORE + assert relations[("rec-002", "rec-004")] == INTERVAL_BEFORE + assert ( + interval_relation_from_current(relations[("rec-002", "rec-003")], False) + == INTERVAL_AFTER + ) + assert ( + interval_relation_from_current(relations[("rec-002", "rec-004")], False) + == INTERVAL_AFTER + ) + + +def test_inverse_is_involution_for_all_thirteen_relations() -> None: + from lineageweave.interval_relation import INTERVAL_RELATION_CODES + + for code in INTERVAL_RELATION_CODES: + flipped = interval_relation_from_current(code, False) + assert interval_relation_from_current(flipped, False) == code + assert interval_relation_from_current(code, True) == code diff --git a/tests/test_lineage_ingestion.py b/tests/test_lineage_ingestion.py index dc9bbd142..488b6066b 100644 --- a/tests/test_lineage_ingestion.py +++ b/tests/test_lineage_ingestion.py @@ -9,9 +9,15 @@ import pytest import backend.app.lineage_ingestion as ingestion -from backend.app.lineage_ingestion import lineage_graphs_for_posts +from backend.app.lineage_ingestion import ( + interval_relations_for_post, + lineage_graphs_for_posts, + persist_lineage_edges, + visible_lineage_graph, +) from lineageweave.fixtures import sample_records from lineageweave.lineage_persistence import lineage_edge_specs +from lineageweave.models import Edge def test_missing_weight_table_is_detected_without_an_aborting_query() -> None: @@ -409,6 +415,27 @@ def test_seed_shaped_rows_rebuild_to_the_designed_a100_fork() -> None: assert "rec-006" not in {edge.child_id for edge in edges} +def test_persist_requires_observed_points_before_replacing_edges() -> None: + class FakeConnection: + calls: list[str] = [] + + async def execute(self, query: str, *_args): + self.calls.append(query) + + connection = FakeConnection() + edge = Edge("parent", "child", 0.8, {}) + + with pytest.raises(ValueError, match="child"): + asyncio.run( + persist_lineage_edges( + connection, + [edge], + {"parent": {"created_at": datetime(2026, 1, 1)}}, + ) + ) + assert connection.calls == [] + + def test_rebuild_fails_closed_without_an_activated_weight_estimate() -> None: """ADR 0200 point 1: no activated estimate -> no reconstruction on constants. The raised message names the next action (run the @@ -549,7 +576,7 @@ class FakeConnection: {"parent_post_id": "post-a", "child_post_id": "post-b", "fused_score": 0.8} ] - async def fetch(self, query: str): + async def fetch(self, query: str, *_args): return self.edges if "post_lineage_edge" in query else self.posts connection = FakeConnection() @@ -574,6 +601,71 @@ async def fetch(self, query: str): assert isolated == {"nodes": [], "edges": [], "truncated": False} +def test_visible_lineage_graph_attaches_allen_labels() -> None: + class FakeConnection: + posts = [ + { + "post_id": "rec-002", + "post_title": "Pricing renegotiation follow-up", + "voc_type_code": "voc", + "visibility_code": "public", + "corporate_entity_id": "corp", + "process_unit_id": "pu", + "thread_group_key": "A-100", + "created_at": datetime(2026, 1, 6), + }, + { + "post_id": "rec-003", + "post_title": "Pricing renegotiation: revised quote sent", + "voc_type_code": "voc", + "visibility_code": "public", + "corporate_entity_id": "corp", + "process_unit_id": "pu", + "thread_group_key": "A-100", + "created_at": datetime(2026, 1, 10), + }, + ] + edges = [ + { + "parent_post_id": "rec-002", + "child_post_id": "rec-003", + "fused_score": 0.9, + "interval_relation_code": "interval_contains", + } + ] + + async def fetch(self, query: str, *_args): + return self.edges if "post_lineage_edge" in query else self.posts + + graph = asyncio.run( + visible_lineage_graph(FakeConnection(), lambda row: True, focus_post_id="rec-002") + ) + assert graph["edges"][0]["interval_relation_code"] == "interval_contains" + assert graph["edges"][0]["interval_relation_label"] == "Contains" + + +def test_interval_relations_for_post_orient_from_the_opened_child() -> None: + class FakeConnection: + edges = [ + { + "parent_post_id": "rec-002", + "child_post_id": "rec-003", + "interval_relation_code": "interval_contains", + } + ] + + async def fetch(self, query: str, *_args): + return self.edges + + from_parent = asyncio.run(interval_relations_for_post(FakeConnection(), "rec-002")) + from_child = asyncio.run(interval_relations_for_post(FakeConnection(), "rec-003")) + assert from_parent["rec-003"]["interval_relation_code"] == "interval_contains" + assert from_parent["rec-003"]["interval_is_parent"] is True + assert from_child["rec-002"]["interval_relation_code"] == "interval_during" + assert from_child["rec-002"]["interval_relation_label"] == "During" + assert from_child["rec-002"]["interval_is_parent"] is False + + def test_lineage_graphs_for_posts_merges_distinct_threads_without_duplicates() -> None: """Global Ask can cite posts from unrelated threads; the merged graph must carry every cited thread (for LineageDag's per-thread git-branch diff --git a/tests/test_migration_replay.py b/tests/test_migration_replay.py index 871caa125..c170f73e8 100644 --- a/tests/test_migration_replay.py +++ b/tests/test_migration_replay.py @@ -50,6 +50,31 @@ def test_migrate_sh_replays_leftover_pair_migration_on_existing_volumes() -> Non subprocess.run(["sh", "-n", str(migration_script)], check=True) +def test_interval_relation_backfill_uses_utc_created_day() -> None: + sql = ( + Path(__file__).resolve().parents[1] + / "migrations" + / "0140_post_lineage_interval_relation.sql" + ).read_text(encoding="utf-8") + + assert "created_at at time zone 'UTC'" in sql + + +def test_interval_relation_foreign_key_validation_is_separate() -> None: + """Installing the FK must not scan a large existing edge table.""" + + migrations = Path(__file__).resolve().parents[1] / "migrations" + install_sql = (migrations / "0140_post_lineage_interval_relation.sql").read_text( + encoding="utf-8" + ) + validate_sql = ( + migrations / "0205_validate_post_lineage_interval_relation.sql" + ).read_text(encoding="utf-8") + + assert "not valid" in install_sql.lower() + assert "validate constraint post_lineage_edge_interval_relation_code_fkey" in validate_sql + + def test_migrate_sh_replays_leftover_map_axis_migration_on_existing_volumes() -> None: """migrate.sh's replay window must cover 0169 (report_leftover_map_axis). diff --git a/tests/test_schema.py b/tests/test_schema.py index 328720f3b..d2a2c015f 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -43,6 +43,11 @@ / "migrations" / "0102_project_bound_summary_event.sql" ) +_INTERVAL_RELATION_MIGRATION = ( + Path(__file__).resolve().parents[1] + / "migrations" + / "0140_post_lineage_interval_relation.sql" +) _LEFTOVER_OBSERVED_EXPECTED_MIGRATION = ( Path(__file__).resolve().parents[1] / "migrations" @@ -109,6 +114,7 @@ def schema_db(): cur.execute(_MAJOR_EVENT_ACTION_MIGRATION.read_text()) cur.execute(_PROJECT_BOUND_ACTION_MIGRATION.read_text()) cur.execute(_PROJECT_BOUND_EVENT_MIGRATION.read_text()) + cur.execute(_INTERVAL_RELATION_MIGRATION.read_text()) cur.execute(_LEFTOVER_OBSERVED_EXPECTED_MIGRATION.read_text()) cur.execute(_LEFTOVER_MAP_RANK_MIGRATION.read_text()) cur.execute(_LEFTOVER_MAP_COVERAGE_MIGRATION.read_text()) @@ -170,6 +176,27 @@ def test_migration_applies_cleanly(schema_db) -> None: assert expected <= tables +def test_post_lineage_edge_requires_an_allen_interval_code(schema_db) -> None: + with schema_db.cursor() as cur: + cur.execute( + """ + select is_nullable + from information_schema.columns + where table_name = 'post_lineage_edge' + and column_name = 'interval_relation_code' + """ + ) + assert cur.fetchone()[0] == "NO" + cur.execute( + "select lookup_code from common_lookup_value " + "where lookup_category = 'interval_relation' order by display_order" + ) + codes = [row[0] for row in cur.fetchall()] + assert "interval_contains" in codes + assert "interval_overlaps" in codes + assert len(codes) == 13 + + def test_major_event_action_project_reference_is_normalized(schema_db) -> None: with schema_db.cursor() as cur: cur.execute( diff --git a/uv.lock b/uv.lock index da3f4fd54..e0cd6ec6c 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "2.15.0" +version = "2.15.1" source = { editable = "." } dependencies = [ { name = "certifi" },