From 7edc1e33de39933eca48052ebb631e258edd47b1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 11:31:54 +0000 Subject: [PATCH 01/14] feat: name Allen interval relations on Event Lineage edges (v2.15.0) Persist interval_relation_code on post_lineage_edge after reconstruct chooses a parent. Ticket-aware windows (created day through earliest open due date) make the A-100 pricing follow-up Contain the revised quote and Overlap the delivery question. Click Contains to open the revised quote. ADR 0122. Independent of leftover-map #481 and SKOS chips #482. --- AGENTS.md | 7 + ARCHITECTURE.md | 8 +- .../2.15.0-event-lineage-interval-relation.md | 20 +++ CHANGELOG.md | 10 ++ CLAUDE.md | 6 + backend/app/lineage_ingestion.py | 98 ++++++++++- backend/app/main.py | 27 ++- backend/tests/test_api.py | 93 +++++++++++ docker/postgres-init/migrate.sh | 2 +- .../0122-event-lineage-interval-relation.md | 62 +++++++ docs/storybook-inventory.md | 2 + frontend/package.json | 2 +- frontend/src/App.css | 52 ++++++ frontend/src/App.test.tsx | 30 +++- frontend/src/App.tsx | 3 + frontend/src/LineageDag.stories.tsx | 85 ++++++++++ frontend/src/LineageDag.test.tsx | 70 ++++++++ frontend/src/LineageDag.tsx | 88 ++++++++-- frontend/src/api.ts | 5 + frontend/src/i18n.test.ts | 4 + frontend/src/i18n.ts | 68 ++++++++ lineageweave/__init__.py | 2 +- lineageweave/interval_relation.py | 156 ++++++++++++++++++ .../0105_post_lineage_interval_relation.sql | 52 ++++++ .../0105_post_lineage_interval_relation.sql | 5 + pyproject.toml | 2 +- scripts/seed_demo_data.py | 63 ++++++- tests/test_interval_relation.py | 119 +++++++++++++ tests/test_lineage_ingestion.py | 68 +++++++- tests/test_migration_replay.py | 18 ++ tests/test_schema.py | 27 +++ uv.lock | 2 +- 32 files changed, 1214 insertions(+), 42 deletions(-) create mode 100644 CHANGELOG.d/2.15.0-event-lineage-interval-relation.md create mode 100644 docs/adr/0122-event-lineage-interval-relation.md create mode 100644 frontend/src/LineageDag.stories.tsx create mode 100644 frontend/src/LineageDag.test.tsx create mode 100644 lineageweave/interval_relation.py create mode 100644 migrations/0105_post_lineage_interval_relation.sql create mode 100644 migrations/rollback/0105_post_lineage_interval_relation.sql create mode 100644 tests/test_interval_relation.py diff --git a/AGENTS.md b/AGENTS.md index 1728f9e61..90f2a07ee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -193,6 +193,13 @@ Gabriel factorization. Closest and farthest post–criterion pairs persist to `report_leftover_pair` and sit above the member list so a click opens that post. +Event Lineage edges persist an Allen (1983) `interval_relation_code` +(ADR 0122 / v2.15.0). Reconstruct still owns the parent choice; this +only names the dated windows. A missing or earlier due date is a +point interval. After `make seed`, rec-002 **Contains** rec-003 and +**Overlaps** rec-004. Click Contains to open the revised quote. Do +not treat During/Contains as causation. + `frontend/` has its own toolchain (Node pinned via `frontend/mise.toml`, pnpm via Corepack -- do not add a second Node package manager or a floating Node version): diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d0280ff97..5cc4ca068 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. Ticket-aware windows: created day through the earliest open `issue_ticket.due_date`. Does not choose a parent or invent a fused score. | | `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`) | @@ -215,9 +216,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 0122) computed from the two posts' dated 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 Allen +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.0-event-lineage-interval-relation.md b/CHANGELOG.d/2.15.0-event-lineage-interval-relation.md new file mode 100644 index 000000000..32b3d4a4b --- /dev/null +++ b/CHANGELOG.d/2.15.0-event-lineage-interval-relation.md @@ -0,0 +1,20 @@ +# 2.15.0 — Name Allen interval relations on Event Lineage edges + +## Added + +- Persist `interval_relation_code` on `post_lineage_edge` (ADR 0122). After + reconstruct chooses a parent, the product names the Allen (1983) relation + between the two posts' dated windows. A post without an open ticket is a + point interval on its created day. An earlier due date is ignored, never + inverted. +- After `make seed`, the A-100 pricing follow-up **Contains** the revised + quote and **Overlaps** the delivery question. Click Contains to open the + revised quote. 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 c8ed1a099..ff5b9eb83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,16 @@ All notable changes to this project are documented here. Format follows environment, so local OIDC and synthetic-data workflows resolve the same pinned dependencies as CI. +## [2.15.0] - 2026-08-23 + +### Added + +- Event Lineage edges now name the Allen (1983) interval between the two + posts' dated windows (created day through the earliest open ticket due + date). After `make seed`, the A-100 pricing follow-up **Contains** the + revised quote and **Overlaps** the delivery question. Click Contains to + open the revised quote. Indirect Keyman links stay unlabeled. + ## [2.12.6] - 2026-08-20 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 1bcf50763..200165bf0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,6 +35,12 @@ after cutoff were rewritten after the run; the opened body names both clocks and shows **Body this run knew** beside the live rewrite. Compare those two texts before treating the live body as reconstructed evidence (ADR 0016 / 0025). +After `make seed`, open the A-100 pricing follow-up in Event Lineage. +**Contains** names the revised quote (point 2026-01-10 inside the +2026-01-06..2026-01-12 ticket window) and **Overlaps** names the +delivery question. Click Contains to open the revised quote +(ADR 0122 / v2.15.0). Reconstruct still owns the parent; the label +is not a causal claim. `POST /api/analysis-runs` records Pending lineage only on an authorized cutoff capture (ADR 0017). TEPP and period-report kinds are 422. The Request button waits until affiliated corps load; choose diff --git a/backend/app/lineage_ingestion.py b/backend/app/lineage_ingestion.py index f1e76d495..1cba26dcc 100644 --- a/backend/app/lineage_ingestion.py +++ b/backend/app/lineage_ingestion.py @@ -16,6 +16,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 @@ -59,19 +65,55 @@ 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 window toward the child window.""" + return allen_interval_relation( + interval_from_post(parent_row["created_at"], parent_row.get("due_date")), + interval_from_post(child_row["created_at"], child_row.get("due_date")), + ) + + +async def persist_lineage_edges( + conn: asyncpg.Connection, + edges: list[Edge], + bounds_by_post_id: Mapping[str, Mapping[str, Any]] | None = None, +) -> None: """Replace ``post_lineage_edge`` with ``edges`` (reconstruct is source of truth).""" await conn.execute("delete from post_lineage_edge") + bounds = bounds_by_post_id or {} for edge in edges: + parent_bounds = bounds.get(edge.parent_id) + child_bounds = bounds.get(edge.child_id) + if parent_bounds is not None and child_bounds is not None: + relation_code = interval_relation_code_for_edge(parent_bounds, child_bounds) + else: + relation_code = "interval_before" 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, ) +async def _post_interval_bounds(conn: asyncpg.Connection) -> dict[str, dict[str, Any]]: + """Created day plus earliest open ticket due date, keyed by post id.""" + rows = await conn.fetch( + "select source_post.post_id, source_post.created_at, " + "(select min(issue_ticket.due_date) from issue_ticket " + " where issue_ticket.post_id = source_post.post_id " + " and issue_ticket.ticket_status_code <> 'closed' " + " and issue_ticket.due_date is not null) as due_date " + f"from source_post where {SOURCE_POST_ELIGIBILITY_SQL.format(alias='source_post')}" + ) + return {str(row["post_id"]): row for row in rows} + + async def rebuild_lineage(conn: asyncpg.Connection) -> list[Edge]: """Reconstruct lineage for every ``source_post`` and persist the edges.""" rows = await conn.fetch( @@ -80,10 +122,21 @@ async def rebuild_lineage(conn: asyncpg.Connection) -> list[Edge]: f"from source_post where {SOURCE_POST_ELIGIBILITY_SQL.format(alias='source_post')}" ) edges = lineage_edge_specs(records_from_source_posts(rows)) - await persist_lineage_edges(conn, edges) + await persist_lineage_edges(conn, edges, await _post_interval_bounds(conn)) 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, @@ -103,7 +156,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: @@ -170,7 +224,41 @@ 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 + diff --git a/backend/app/main.py b/backend/app/main.py index fb943315f..af88855b4 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -165,7 +165,11 @@ visible_mention_post_ids, visible_team_mention_post_ids, ) -from backend.app.lineage_ingestion import rebuild_lineage, visible_lineage_graph +from backend.app.lineage_ingestion import ( + interval_relations_for_post, + rebuild_lineage, + visible_lineage_graph, +) from backend.app.post_chat_ingestion import ( fetch_persisted_chat, fetch_persisted_chats, @@ -2163,22 +2167,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 438b4786a..764acf12d 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -113,6 +113,11 @@ / "migrations" / "0102_project_bound_summary_event.sql" ) +_INTERVAL_RELATION_MIGRATION = ( + Path(__file__).resolve().parents[2] + / "migrations" + / "0105_post_lineage_interval_relation.sql" +) def _postgres_available() -> bool: @@ -226,6 +231,7 @@ def seeded_db(demo_analyst_token): 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( "insert into common_lookup_value (lookup_category, lookup_code, lookup_label) values " "('corporate_entity_level', 'group', 'Group'), " @@ -3726,6 +3732,93 @@ 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_names_a100_ticket_windows(client, demo_analyst_token, seeded_db) -> None: + """Ticket-aware windows make rec-002 contain rec-003 and overlap rec-004. + + Point-only created_at days would label every A-100 fork edge Before. + The same tickets `make seed` writes turn those into Contains/Overlaps. + """ + 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_contains" + assert quote_edge["interval_relation_label"] == "Contains" + assert delivery_edge["interval_relation_code"] == "interval_overlaps" + assert delivery_edge["interval_relation_label"] == "Overlaps" + + 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_contains" + assert by_title["Delivery schedule question raised"]["interval_relation_code"] == "interval_overlaps" + + 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_during" + assert quote_direct[fork["id"]]["interval_relation_label"] == "During" + 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 diff --git a/docker/postgres-init/migrate.sh b/docker/postgres-init/migrate.sh index f329117d6..8cf496127 100644 --- a/docker/postgres-init/migrate.sh +++ b/docker/postgres-init/migrate.sh @@ -18,7 +18,7 @@ for migration in /opt/lineageweave/migrations/*.sql; do migration_name=${migration##*/} case "$migration_name" in 0012_*|0013_*|0014_*|0015_*|0016_*|0017_*|0018_*|0019_*|0020_*|0021_*|0022_*|0023_*|0024_*|0025_*|0026_*|0027_*|0028_*|0029_*|0030_*|0031_*|0032_*|0033_*|0034_*|0035_*|0036_*|0037_*|0038_*|0039_*|0040_*|0041_*|0042_*|0043_*|0044_*|0045_*|0046_*|0047_*|0048_*|0049_*|0050_*) ;; - 0060_*|0100_*|0101_*|0102_*) ;; + 0060_*|0100_*|0101_*|0102_*|0105_*) ;; *) continue ;; esac printf 'Applying %s\n' "$migration_name" diff --git a/docs/adr/0122-event-lineage-interval-relation.md b/docs/adr/0122-event-lineage-interval-relation.md new file mode 100644 index 000000000..d8d220734 --- /dev/null +++ b/docs/adr/0122-event-lineage-interval-relation.md @@ -0,0 +1,62 @@ +# ADR 0122 — 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, during the parent's open ticket window, 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 dated window is observed: `source_post.created_at` as the +start, and the earliest *open* `issue_ticket.due_date` as the end +when that due date is on or after the created day. A missing or +earlier due date is a point interval. The product does not invent a +duration, swap bounds, 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' dated +windows after reconstruct has chosen the parent. Rebuild and seed +write the code in the same transaction as the edge. Ticket-aware +windows run after fixture tickets exist so `make seed` is not a +point-only map. + +`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 **contains** the +revised quote (point 2026-01-10 inside the 2026-01-06..2026-01-12 +ticket window) and **overlaps** the delivery question. Click the +Contains row to open the revised quote. Migration +`0105_post_lineage_interval_relation.sql` upgrades volumes that +already applied `0001`. Point-only backfill uses created days so +existing edges are never left null. + +## 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 28c59bd48..9d817c5c4 100644 --- a/docs/storybook-inventory.md +++ b/docs/storybook-inventory.md @@ -6,6 +6,8 @@ buyer-facing control you can click before changing product CSS. | Story | Buyer next action | Token / module | |---|---|---| | `Evidence/CitationChip` | Click a cited title to open that source post. | `--color-chip-border`, `--radius-chip`, `CitationChip` | +| `Lineage/LineageDag` | Read Contains on the A-100 fork, then click that row to open the revised quote. | `--color-accent-background`, `--radius-control`, `LineageDag` | +| `AnalysisRun/CutoffKnownBody` | Read the cutoff-known sentence, then compare it with the live body below. | `--color-accent-border`, `--space-panel-block`, `--radius-panel`, `CutoffKnownBody` | | `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` | | `Chrome/PopupCloseButton` | Close the evidence panel or post popup. | `--space-close-inset`, `--font-size-close`, `PopupCloseButton` | diff --git a/frontend/package.json b/frontend/package.json index e2e996bbe..f499c9871 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "2.12.6", + "version": "2.15.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.css b/frontend/src/App.css index c72aab078..0c56b7675 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -543,6 +543,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 7462abd2c..a5d14a89c 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -1027,8 +1027,20 @@ describe("App, authenticated", () => { ], edges: [ { source: "post-1", target: "post-2", fused_score: 0.8 }, - { source: "rec-002", target: "rec-003", fused_score: 0.9 }, - { source: "rec-002", target: "rec-004", fused_score: 0.85 }, + { + 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", + }, ], }), ); @@ -1491,7 +1503,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" }], }), ); @@ -1885,6 +1905,7 @@ describe("App, authenticated", () => { expect(screen.getByText("Sales-lead specificity: 3")).toBeInTheDocument(); expect(screen.getByRole("heading", { name: "Related posts" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Open related post: Linked post" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Open related post: Pricing renegotiation: revised quote sent" })).toBeInTheDocument(); expect(screen.queryByText("Not yet evaluated.")).not.toBeInTheDocument(); }); @@ -1976,7 +1997,10 @@ 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"); + expect(screen.getByRole("button", { name: "Contains: open Pricing renegotiation: revised quote sent" })).toBeInTheDocument(); // The Event Lineage DAG belongs to the opened post, not the list surface. expect(screen.getAllByLabelText("A-100 lineage")).toHaveLength(1); expect(screen.getAllByLabelText("Open post: Pricing renegotiation follow-up")).toHaveLength(1); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6fba0dd41..3ce618c68 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -547,6 +547,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 new file mode 100644 index 000000000..3533121c1 --- /dev/null +++ b/frontend/src/LineageDag.stories.tsx @@ -0,0 +1,85 @@ +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; + +export default meta; + +type Story = StoryObj; + +export const ContainsAndOverlaps: Story = {}; + +export const Empty: Story = { + args: { + graph: { nodes: [], edges: [] }, + }, +}; diff --git a/frontend/src/LineageDag.test.tsx b/frontend/src/LineageDag.test.tsx new file mode 100644 index 000000000..9feead9d1 --- /dev/null +++ b/frontend/src/LineageDag.test.tsx @@ -0,0 +1,70 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +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", + }, + ], +}; + +describe("LineageDag", () => { + it("shows Contains and Overlaps as visible text, not hover-only", () => { + 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(); + }); + + it("opens the revised quote from the Contains keyboard row", async () => { + const onSelectPost = vi.fn(); + render(); + await userEvent.click( + screen.getByRole("button", { + name: "Contains: open Pricing renegotiation: revised quote sent", + }), + ); + expect(onSelectPost).toHaveBeenCalledWith("rec-003"); + }); +}); diff --git a/frontend/src/LineageDag.tsx b/frontend/src/LineageDag.tsx index f082296f8..480dd2910 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"; @@ -6,6 +6,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; +} + export function LineageDag({ graph, onSelectPost, @@ -24,6 +35,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]); return (
@@ -45,20 +57,40 @@ export function LineageDag({ const to = byId[edge.target]; if (!from || !to) return null; 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: from.label, + to: to.label, + score: edge.fused_score.toFixed(2), + relation: t(relation), + }) + : tf("{from} follows {to} ({score})", { + from: from.label, + to: to.label, + score: edge.fused_score.toFixed(2), + })} + + + {relation ? ( + + {t(relation)} + + ) : null} + ); })} {group.nodes.map((node) => { @@ -95,6 +127,34 @@ 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 cd0141a32..69dda7e64 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 { @@ -406,6 +409,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 5a3afbfc3..256bcd90f 100644 --- a/frontend/src/i18n.test.ts +++ b/frontend/src/i18n.test.ts @@ -36,6 +36,10 @@ describe("i18n", () => { "Page", "Answer", "Showing the first {shown} of {total} posts known at this cutoff.", + "Contains", + "Overlaps", + "Interval relations", + "Click Contains to open the revised quote.", ] as const; it("supports the five product locales", () => { diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index 650acfca8..36c7eaa06 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -303,6 +303,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": "시간 구간 관계", + "{relation}: open {label}": "{relation}: {label} 열기", + Before: "이전", + After: "이후", + Meets: "바로 이음", + "Met by": "바로 이어짐", + Overlaps: "겹침", + "Overlapped by": "겹침됨", + Starts: "함께 시작", + "Started by": "시작 포함", + During: "동안", + Contains: "포함", + Finishes: "함께 끝", + "Finished by": "끝 포함", + Equals: "같은 구간", + "Click Contains to open the revised quote.": "포함을 눌러 개정 견적을 여세요.", "{label} — {date}": "{label} — {date}", "Corporate entity to reconstruct": "재구성할 법인", "Next action": "다음 작업", @@ -641,6 +658,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": "时间区间关系", + "{relation}: open {label}": "{relation}:打开 {label}", + Before: "早于", + After: "晚于", + Meets: "相接", + "Met by": "被相接", + Overlaps: "重叠", + "Overlapped by": "被重叠", + Starts: "同时开始", + "Started by": "开始包含", + During: "期间", + Contains: "包含", + Finishes: "同时结束", + "Finished by": "结束包含", + Equals: "相同区间", + "Click Contains to open the revised quote.": "点击包含以打开修订报价。", "{label} — {date}": "{label} — {date}", "Corporate entity to reconstruct": "要重建的法人实体", "Next action": "下一步操作", @@ -979,6 +1013,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": "時間区間の関係", + "{relation}: open {label}": "{relation}: {label}を開く", + Before: "前", + After: "後", + Meets: "直後", + "Met by": "直前", + Overlaps: "重なる", + "Overlapped by": "重ねられる", + Starts: "同時開始", + "Started by": "開始を含む", + During: "期間内", + Contains: "含む", + Finishes: "同時終了", + "Finished by": "終了を含む", + Equals: "同じ区間", + "Click Contains to open the revised quote.": "含むを押して改訂見積を開いてください。", "{label} — {date}": "{label} — {date}", "Corporate entity to reconstruct": "再構成する法人", "Next action": "次の操作", @@ -1317,6 +1368,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", + "{relation}: open {label}": "{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", + "Click Contains to open the revised quote.": "Nhấn Chứa để mở báo giá đã sửa.", "{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/__init__.py b/lineageweave/__init__.py index 95330cb50..8775fe6c6 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "2.12.6" +__version__ = "2.15.0" diff --git a/lineageweave/interval_relation.py b/lineageweave/interval_relation.py new file mode 100644 index 000000000..95373f421 --- /dev/null +++ b/lineageweave/interval_relation.py @@ -0,0 +1,156 @@ +"""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 the two posts' dated +windows so a buyer can see *how* they relate in time, not only that +a fused score attached them. A post without an open dated ticket is +a degenerate point interval on its ``created_at`` day. A due date +earlier than that day is ignored -- it is never inverted into a +fabricated window. + +Does not invent a theta, a fused score, or a lineage parent. +""" + +from __future__ import annotations + +from datetime import date, datetime + +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 or date to a calendar day.""" + if isinstance(value, datetime): + return value.date() + return value + + +def interval_from_post( + created_at: datetime | date, due_date: date | None +) -> ClosedInterval: + """Closed day window: created day through an open ticket due date. + + Missing or earlier due dates collapse to a point interval. The + product never invents a later end or swaps the bounds. + """ + start = calendar_day(created_at) + if due_date is None: + return start, start + end = calendar_day(due_date) + if end < start: + return start, start + return start, end + + +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 + raise ValueError(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/0105_post_lineage_interval_relation.sql b/migrations/0105_post_lineage_interval_relation.sql new file mode 100644 index 000000000..196e347d9 --- /dev/null +++ b/migrations/0105_post_lineage_interval_relation.sql @@ -0,0 +1,52 @@ +-- ADR 0122: 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 references common_lookup_value (lookup_code); + +update post_lineage_edge as edge + set interval_relation_code = case + when parent_post.created_at::date < child_post.created_at::date then 'interval_before' + when parent_post.created_at::date > child_post.created_at::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/rollback/0105_post_lineage_interval_relation.sql b/migrations/rollback/0105_post_lineage_interval_relation.sql new file mode 100644 index 000000000..afe1b3a3e --- /dev/null +++ b/migrations/rollback/0105_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 cb4be2916..2b9af9ee2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "2.12.6" +version = "2.15.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 8ec15a065..33640f50f 100644 --- a/scripts/seed_demo_data.py +++ b/scripts/seed_demo_data.py @@ -130,6 +130,7 @@ def seed( cur.execute((migrations / "0023_analysis_run_outbox.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 / "0105_post_lineage_interval_relation.sql").read_text()) cur.execute( """ insert into common_lookup_value (lookup_category, lookup_code, lookup_label, display_order) values @@ -161,7 +162,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 """ ) @@ -422,6 +436,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, @@ -515,8 +530,9 @@ def _seed_reconstructed_lineage(cur, author_account_id, corporate_entity_id, pro ) for edge in lineage_edge_specs(persisted): 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), ) @@ -952,6 +968,47 @@ def _seed_fixture_tickets(cur) -> None: ) +def _seed_lineage_interval_relations(cur) -> None: + """Name Allen relations after fixture tickets exist (ADR 0122). + + Reconstruct already wrote parent→child edges. Point-only created_at + windows would label every A-100 fork edge Before. Ticket due dates + turn the pricing follow-up into Contains/Overlaps so seed is not a + point-only map. Idempotent: every persisted edge is rewritten. + """ + 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, + (select min(issue_ticket.due_date) from issue_ticket + where issue_ticket.post_id = parent_post.post_id + and issue_ticket.ticket_status_code <> 'closed' + and issue_ticket.due_date is not null) as parent_due, + (select min(issue_ticket.due_date) from issue_ticket + where issue_ticket.post_id = child_post.post_id + and issue_ticket.ticket_status_code <> 'closed' + and issue_ticket.due_date is not null) as child_due + 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, parent_due, child_due in rows: + code = allen_interval_relation( + interval_from_post(parent_created, parent_due), + interval_from_post(child_created, child_due), + ) + 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..4644b32df --- /dev/null +++ b/tests/test_interval_relation.py @@ -0,0 +1,119 @@ +"""Allen interval relations are exhaustive and ticket windows stay honest.""" + +from __future__ import annotations + +from datetime import date, datetime + +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, +) +from lineageweave.lineage_persistence import lineage_edge_specs + + +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_missing_due_date_is_a_point_interval() -> None: + assert interval_from_post(datetime(2026, 1, 6, 15, 0, 0), None) == ( + _d(1, 6), + _d(1, 6), + ) + + +def test_earlier_due_date_is_not_inverted() -> None: + assert interval_from_post(_d(1, 10), _d(1, 6)) == (_d(1, 10), _d(1, 10)) + + +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_ticket_windows_contain_and_overlap_the_designed_fork() -> None: + """Seed tickets make rec-002 contain rec-003 and overlap rec-004. + + Pricing follow-up is 2026-01-06 with due 2026-01-12. Revised quote + is a point on 2026-01-10. Delivery question is 2026-01-07 through + 2026-01-16. Reconstruct still owns the parent choice; this only + names the Allen relation on those edges. + """ + records = {record.record_id: record for record in sample_records()} + dues = { + "rec-002": date(2026, 1, 12), + "rec-004": date(2026, 1, 16), + "rec-102": date(2026, 1, 14), + } + edges = lineage_edge_specs(sample_records()) + relations = { + (edge.parent_id, edge.child_id): allen_interval_relation( + interval_from_post(records[edge.parent_id].occurred_at, dues.get(edge.parent_id)), + interval_from_post(records[edge.child_id].occurred_at, dues.get(edge.child_id)), + ) + for edge in edges + } + assert relations[("rec-001", "rec-002")] == INTERVAL_BEFORE + assert relations[("rec-002", "rec-003")] == INTERVAL_CONTAINS + assert relations[("rec-002", "rec-004")] == INTERVAL_OVERLAPS + assert ( + interval_relation_from_current(relations[("rec-002", "rec-003")], False) + == INTERVAL_DURING + ) + assert ( + interval_relation_from_current(relations[("rec-002", "rec-004")], False) + == INTERVAL_OVERLAPPED_BY + ) + + +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 de8f289c6..075329f2c 100644 --- a/tests/test_lineage_ingestion.py +++ b/tests/test_lineage_ingestion.py @@ -6,6 +6,7 @@ from datetime import datetime, timezone from backend.app.lineage_ingestion import ( + interval_relations_for_post, reconstruct_group_key, records_from_source_posts, visible_lineage_graph, @@ -133,7 +134,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() @@ -150,3 +151,68 @@ async def fetch(self, query: str): assert len(focused["edges"]) == 1 assert focused["truncated"] is False 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 diff --git a/tests/test_migration_replay.py b/tests/test_migration_replay.py index 29fe1c176..128132c12 100644 --- a/tests/test_migration_replay.py +++ b/tests/test_migration_replay.py @@ -33,3 +33,21 @@ def test_migrate_sh_replays_leftover_pair_migration_on_existing_volumes() -> Non ).read_text(encoding="utf-8") assert "0012_*" in script + + +def test_migrate_sh_replays_interval_relation_migration_on_existing_volumes() -> None: + """migrate.sh must cover 0105 so existing volumes get the Allen column. + + 0001 creates post_lineage_edge without interval_relation_code. A + volume that already ran 0001 never sees that column unless + migrate.sh replays 0105. GET /api/lineage then 500s on the new + SELECT, and rebuild cannot persist Contains/Overlaps. + """ + script = ( + Path(__file__).resolve().parents[1] + / "docker" + / "postgres-init" + / "migrate.sh" + ).read_text(encoding="utf-8") + + assert "0105_*" in script diff --git a/tests/test_schema.py b/tests/test_schema.py index 1e2c708a3..da56290be 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" + / "0105_post_lineage_interval_relation.sql" +) def _postgres_available() -> bool: @@ -79,6 +84,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()) conn.commit() yield conn finally: @@ -132,6 +138,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 10bcf9ff1..574c44962 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "2.12.6" +version = "2.15.0" source = { editable = "." } dependencies = [ { name = "certifi" }, From 5d415064d467947584321c7b0f5d2e1852bc88e2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 23 Aug 2026 19:46:57 +0900 Subject: [PATCH 02/14] fix: restore PR 481 CI contracts --- frontend/src/App.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3ce618c68..455034506 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4613,7 +4613,8 @@ export default function App({ showLabPanels = false }: { showLabPanels?: boolean
- {destination === "admin" ? : null}