diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index b5d31877b..e97255385 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,5 +1,52 @@ # Product & Technical Gap Baseline +> Current authority overlay, 2026-09-27 KST. Remote protected `main` is +> `83eba56149eb802cd63642c507c324c9976ec78e` (verified with +> `git ls-remote`). REST pagination returned 169 open PRs and 28 open issues. +> #1129 was reviewed at exact head +> `4bcbc9195c81e68fcf15173ffe6975b872b974a4`. It is Ready with normal +> squash auto-merge enabled; current-head checks remain queued and no +> independent APPROVE exists. Its Storybook fixture now carries primary and +> derived Voice relationships plus separate evidence identity. The exact head +> of this document update must be re-fetched from the PR before a merge +> decision; this observation names the reviewed parent commit. #1128 is at +> `91143146623948dbd26bbfc1c69de3cd77d2ae06`, #1126 at +> `c0c5204b702d2d4d24928389db7d04ebe5cb9739`, and #1123 at +> `fb3dba7e6b8145603389d211a19dbe70280bdea6`. #1128 and #1126 are +> Ready with squash auto-merge enabled; #1123 remains Draft. None has a +> current-head independent APPROVE. A successful bot review or an older +> head's checks do not close +> either gate. Parent PRs must reach protected `main` before stacked children +> are retargeted and rechecked. Older overlays below are dated history. +> +> Authority and gap selected for this slice: the current PRD requires +> authorized evidence for every additional Voice and parity across the graph, +> exact-value CSV, and JSON-LD. ADR 0246 governs the twelve extensible atomic +> Voices; ADR 0256 governs evidence-bearing composition and forbids replacing +> missing evidence with the carrying Post. Current `main` filters graph nodes +> during in-page search but retained a derived Voice's CSV row and JSON-LD +> relationship when that search removed its separate evidence Post. This +> candidate removes that assignment from the searched view and export and +> preserves it when the evidence Post remains visible. The synthetic frontend +> regression is local candidate evidence only. A second regression found that +> a matching graph edge could retain an evidence Post identifier absent from +> the authorized node set; the searched view now drops that dangling edge +> before deriving Voice visibility or exports. The `SeparateVoiceEvidence` +> fixture now includes exportable JSON-LD for the primary and derived Voice +> relationships and binds the derived relation to its distinct evidence Post. +> A local Storybook build at this head rendered the separate-evidence scene at +> 1440×900 and 390×844; screenshots were visually checked in the isolated +> worktree. No authenticated browser interaction, Firefox/WebKit, +> assistive-technology, or eight-locale result is bound to this exact head. +> Authenticated PostgreSQL API and protected-main acceptance remain unverified. +> This slice changes no ADR, API, schema, migration ordinal, or release number. +> Its baseline path overlaps draft #1123, and its Storybook inventory path +> overlaps draft #1126; reconcile those exact paths before either later merge. +> Draft #1121 changes the Voice-history ADR and documentation tests, while +> #997 changes the occupational ADR and PRD. Their policy/requirement changes +> are not inherited by this frontend candidate or treated as protected-main +> authority. Each stack still needs a fresh conflict and exact-head audit. + > Exact-head loop overlay: 2026-08-29 13:20 KST. Protected `main` is > `fc13acaa20adca11968238e398d4aafcf62b6cee` (v2.23.0 leftover-map > explained leftover share, #775). Open ready PRs still lack independent diff --git a/docs/storybook-inventory.md b/docs/storybook-inventory.md index f426285a6..6d549f8c3 100644 --- a/docs/storybook-inventory.md +++ b/docs/storybook-inventory.md @@ -13,7 +13,7 @@ operator-facing control you can click before changing product CSS. | `Post/Connect perspective` | Choose one unassigned Voice and an explicit evidence state, then record the open post as its evidence. `Ready`, `Completed`, and `NarrowViewport` cover untouched, successful, and mobile states. | `VoiceAssignmentForm`, `admin-form`, `btn-primary` | | `Evidence/CitationChip` | Click a cited title to open that source post. | `--color-chip-border`, `--radius-chip`, `CitationChip` | | `Evidence/OrganizationAliasChip` | Click a cataloged org; the parenthetical is the unique corroborated SKOS companion. | `--color-chip-border`, `--radius-chip`, `OrganizationAliasChip` | -| `Evidence/OntologyExplorer` | Distinguish Event Lineage from typed ontology facts, inspect Post/Person/Organization/Team/Project shapes, token-backed secondary cues, and truth labels, then open authorized evidence. The named exact-values region supports keyboard scrolling; `LongLabelsAndEvidenceTable` proves complete labels wrap without character-count truncation, while `CombinedVoiceEvidence` covers primary-plus-additional Voice assignments and focuses the evidence action distinct from the carrying-Post action. Desktop, narrow, drawers, legend/filter, empty, truncated, partial, denied, stale, and rejected scenes cover ADR 0184/0222/0251 states. | `OntologyExplorer`, `ontologyLayout`, `--ontology-node-*-fill`, `--color-table-border` | +| `Evidence/OntologyExplorer` | Distinguish Event Lineage from typed ontology facts, inspect Post/Person/Organization/Team/Project shapes, token-backed secondary cues, and truth labels, then open authorized evidence. The named exact-values region supports keyboard scrolling; `LongLabelsAndEvidenceTable` proves complete labels wrap without character-count truncation, while `CombinedVoiceEvidence` covers primary-plus-additional Voice assignments and focuses the evidence action distinct from the carrying-Post action. `SeparateVoiceEvidence` shows a different evidence Post so filtering the scene can verify that its derived Voice disappears from the searched view and export when that Post is absent. Desktop, narrow, drawers, legend/filter, empty, truncated, partial, denied, stale, and rejected scenes cover ADR 0184/0222/0251 states. | `OntologyExplorer`, `ontologyLayout`, `--ontology-node-*-fill`, `--color-table-border` | | `Evidence/OntologyExplorer` | Distinguish Event Lineage from typed ontology facts, inspect Post/Person/Organization/Team/Project/Work-evidence shapes, token-backed secondary cues, and truth labels, then open authorized evidence. The populated scene includes one assertion-backed occupational construct without a person-trait promotion. The named exact-values region supports keyboard scrolling; `LongLabelsAndEvidenceTable` proves complete labels wrap without character-count truncation. Desktop, narrow, drawers, legend/filter, empty, truncated, partial, denied, stale, and rejected scenes cover ADR 0184/0222/0255 states. | `OntologyExplorer`, `ontologyLayout`, `--ontology-node-*-fill`, `--color-table-border` | | `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` | diff --git a/frontend/src/components/OntologyExplorer.stories.tsx b/frontend/src/components/OntologyExplorer.stories.tsx index 5e192ff0f..6824c67f6 100644 --- a/frontend/src/components/OntologyExplorer.stories.tsx +++ b/frontend/src/components/OntologyExplorer.stories.tsx @@ -3,10 +3,44 @@ import type { OntologyNeighborhoodPayload } from "../api"; import { OntologyExplorer } from "./OntologyExplorer"; const POST_ID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1"; +const VOICE_EVIDENCE_POST_ID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa2"; const PERSON_ID = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbb1"; const CORP_ID = "cccccccc-cccc-cccc-cccc-ccccccccccc1"; const PROJECT_ID = `${POST_ID}/demo-project`; const CONSTRUCT_ID = "99999999-9999-9999-9999-999999999999"; +const ONTOLOGY_NAMESPACE = "https://contextualwisdomlab.github.io/LineageWeave/ontology#"; +const HAS_VOICE_ASSIGNMENT = `${ONTOLOGY_NAMESPACE}hasVoiceAssignment`; +const PRIMARY_VOICE_IRI = `${ONTOLOGY_NAMESPACE}voice-assignment/${POST_ID}/voc`; +const DERIVED_VOICE_IRI = `${ONTOLOGY_NAMESPACE}voice-assignment/${POST_ID}/vops`; + +function voiceJsonLd(evidencePostId: string): OntologyNeighborhoodPayload["jsonld"] { + const postIri = `${ONTOLOGY_NAMESPACE}node/node_post/${POST_ID}`; + const evidenceIri = `${ONTOLOGY_NAMESPACE}node/node_post/${evidencePostId}`; + + return { + "@context": demoNeighborhood.jsonld["@context"], + "@graph": [ + { + "@id": postIri, + [HAS_VOICE_ASSIGNMENT]: [ + { "@id": PRIMARY_VOICE_IRI }, + { "@id": DERIVED_VOICE_IRI }, + ], + }, + ...(evidencePostId === POST_ID ? [] : [{ "@id": evidenceIri }]), + { + "@id": PRIMARY_VOICE_IRI, + "prov:wasDerivedFrom": { "@id": postIri }, + }, + { + "@id": DERIVED_VOICE_IRI, + "prov:wasDerivedFrom": { "@id": evidenceIri }, + }, + { "@id": `${ONTOLOGY_NAMESPACE}voiceOfCustomerType` }, + { "@id": `${ONTOLOGY_NAMESPACE}voiceOfProcessType` }, + ], + }; +} const demoNeighborhood: OntologyNeighborhoodPayload = { focus_node_id: POST_ID, @@ -302,6 +336,7 @@ const combinedVoiceNeighborhood: OntologyNeighborhoodPayload = { evidence_post_id: POST_ID, })), ], + jsonld: voiceJsonLd(POST_ID), }; const meta = { @@ -331,6 +366,33 @@ export const CombinedVoiceEvidence: Story = { }, }; +export const SeparateVoiceEvidence: Story = { + args: { + neighborhood: { + ...combinedVoiceNeighborhood, + nodes: [ + ...combinedVoiceNeighborhood.nodes, + { + ...combinedVoiceNeighborhood.nodes[0], + node_id: VOICE_EVIDENCE_POST_ID, + display_label: "Synthetic evidence post", + }, + ], + voice_assignments: combinedVoiceNeighborhood.voice_assignments?.map((assignment) => + assignment.is_primary + ? assignment + : { ...assignment, evidence_post_id: VOICE_EVIDENCE_POST_ID }, + ), + exact_value_rows: combinedVoiceNeighborhood.exact_value_rows.map((row) => + row.edge_id.endsWith(":vops") + ? { ...row, evidence_post_id: VOICE_EVIDENCE_POST_ID } + : row, + ), + jsonld: voiceJsonLd(VOICE_EVIDENCE_POST_ID), + }, + }, +}; + export const LongLabelsAndEvidenceTable: Story = { args: { neighborhood: { diff --git a/frontend/src/ontologyLayout.test.ts b/frontend/src/ontologyLayout.test.ts index b609e7e2b..62e59846e 100644 --- a/frontend/src/ontologyLayout.test.ts +++ b/frontend/src/ontologyLayout.test.ts @@ -159,6 +159,101 @@ describe("ontologyLayout", () => { expect(accumulateNeighborhoodPages(source, withVoice).voice_assignments).toEqual([assignment]); }); + it("omits derived voices when a search hides their evidence post", () => { + const source = payload(); + const evidenceId = "dddddddd-dddd-dddd-dddd-ddddddddddd1"; + const assignmentIri = `${ONTOLOGY_NAMESPACE}voice-assignment/${POST_ID}/vops`; + const primaryIri = `${ONTOLOGY_NAMESPACE}voice-assignment/${POST_ID}/voc`; + const postIri = `${ONTOLOGY_NAMESPACE}node/node_post/${POST_ID}`; + const evidenceIri = `${ONTOLOGY_NAMESPACE}node/node_post/${evidenceId}`; + const relation = `${ONTOLOGY_NAMESPACE}hasVoiceAssignment`; + const assignment = { + post_id: POST_ID, + voice_type_code: "vops", + voice_type_iri: `${ONTOLOGY_NAMESPACE}voiceOfProcessType`, + voice_type_label: "Voice of Process", + is_primary: false, + truth_status_code: "truth_observed", + recorded_at: "2026-01-10T12:00:00+00:00", + provenance_reference: "Evidence-backed additional voice", + evidence_post_id: evidenceId, + }; + const primary = { + ...assignment, + voice_type_code: "voc", + voice_type_iri: `${ONTOLOGY_NAMESPACE}voiceOfCustomerType`, + voice_type_label: "Voice of Customer", + is_primary: true, + evidence_post_id: null, + }; + const row = { + ...source.exact_value_rows[0], + edge_id: `voice-assignment:${POST_ID}:vops`, + property_code: "hasVoiceAssignment", + evidence_post_id: evidenceId, + }; + const withVoice = { + ...source, + nodes: [source.nodes[0], { ...source.nodes[0], node_id: evidenceId, display_label: "Evidence source" }], + edges: [], + exact_value_rows: [row, { ...row, edge_id: `voice-assignment:${POST_ID}:voc`, evidence_post_id: POST_ID }], + voice_assignments: [assignment, primary], + jsonld: { "@graph": [ + { "@id": postIri, "rdfs:label": "Carrying post", [relation]: [ + { "@id": assignmentIri }, { "@id": primaryIri }, + ] }, + { "@id": evidenceIri }, + { "@id": assignmentIri, "prov:wasDerivedFrom": { "@id": evidenceIri } }, + { "@id": primaryIri, "prov:wasDerivedFrom": { "@id": postIri } }, + { "@id": assignment.voice_type_iri }, + { "@id": primary.voice_type_iri }, + ] }, + } satisfies OntologyNeighborhoodPayload; + + const hidden = filterNeighborhood(withVoice, "no match")!; + expect(hidden.voice_assignments).toEqual([primary]); + expect(hidden.exact_value_rows.map((value) => value.edge_id)).toEqual([ + `voice-assignment:${POST_ID}:voc`, + ]); + expect(hidden.jsonld["@graph"]).toEqual([ + { "@id": postIri, "rdfs:label": "Carrying post", [relation]: [{ "@id": primaryIri }] }, + { "@id": primaryIri, "prov:wasDerivedFrom": { "@id": postIri } }, + { "@id": primary.voice_type_iri }, + ]); + + const shown = filterNeighborhood(withVoice, "Evidence source")!; + expect(shown.voice_assignments).toEqual([assignment, primary]); + expect(shown.exact_value_rows.map((value) => value.edge_id)).toEqual([ + row.edge_id, `voice-assignment:${POST_ID}:voc`, + ]); + expect((shown.jsonld["@graph"] as Array>)[0][relation]).toEqual([ + { "@id": assignmentIri }, + { "@id": primaryIri }, + ]); + + const dangling = filterNeighborhood({ + ...withVoice, + nodes: [source.nodes[0]], + edges: [{ + ...source.edges[0], + edge_id: "dangling-evidence-reference", + source_node_type_code: "node_post", + source_node_id: POST_ID, + target_node_type_code: "node_post", + target_node_id: evidenceId, + property_label: "related evidence", + }], + }, "related evidence")!; + expect(dangling.edges).toEqual([]); + expect(dangling.voice_assignments).toEqual([primary]); + expect(dangling.exact_value_rows.map((value) => value.edge_id)).toEqual([ + `voice-assignment:${POST_ID}:voc`, + ]); + expect((dangling.jsonld["@graph"] as Array>)[0][relation]).toEqual([ + { "@id": primaryIri }, + ]); + }); + it("merges JSON-LD properties and multi-value relations for one paged subject", () => { const source = payload(); const postIri = `${ONTOLOGY_NAMESPACE}node/node_post/${POST_ID}`; diff --git a/frontend/src/ontologyLayout.ts b/frontend/src/ontologyLayout.ts index dc49de443..8b3235b0b 100644 --- a/frontend/src/ontologyLayout.ts +++ b/frontend/src/ontologyLayout.ts @@ -29,6 +29,7 @@ const ROW_GAP = 128; const LEFT = ONTOLOGY_NODE_LABEL_WIDTH / 2 + 20; const TOP = 48; const ONTOLOGY_NAMESPACE = "https://contextualwisdomlab.github.io/LineageWeave/ontology#"; +const HAS_VOICE_ASSIGNMENT = `${ONTOLOGY_NAMESPACE}hasVoiceAssignment`; function nodeKey(nodeTypeCode: string, nodeId: string): string { return `${nodeTypeCode}:${nodeId}`; @@ -191,7 +192,8 @@ export function filterNeighborhood( const edges = payload.edges.filter((edge) => { const source = nodesByKey.get(nodeKey(edge.source_node_type_code, edge.source_node_id)); const target = nodesByKey.get(nodeKey(edge.target_node_type_code, edge.target_node_id)); - return edgeMatch(edge) || Boolean(source && nodeMatch(source)) || Boolean(target && nodeMatch(target)); + if (!source || !target) return false; + return edgeMatch(edge) || nodeMatch(source) || nodeMatch(target); }); const keep = new Set([nodeKey(payload.focus_node_type_code, payload.focus_node_id)]); for (const edge of edges) { @@ -202,13 +204,19 @@ export function filterNeighborhood( if (nodeMatch(node)) keep.add(nodeKey(node.node_type_code, node.node_id)); } const nodes = payload.nodes.filter((node) => keep.has(nodeKey(node.node_type_code, node.node_id))); + const visibleVoiceAssignments = (payload.voice_assignments ?? []).filter((assignment) => + keep.has(nodeKey("node_post", assignment.post_id)) && + (assignment.is_primary || + (assignment.evidence_post_id !== null && + assignment.evidence_post_id !== undefined && + keep.has(nodeKey("node_post", assignment.evidence_post_id)))), + ); + const visibleVoiceRowIds = new Set(visibleVoiceAssignments.map( + (assignment) => `voice-assignment:${assignment.post_id}:${assignment.voice_type_code}`, + )); const exact_value_rows = payload.exact_value_rows.filter((row) => edges.some((edge) => edge.edge_id === row.edge_id) || - (payload.voice_assignments ?? []).some( - (assignment) => - row.edge_id === `voice-assignment:${assignment.post_id}:${assignment.voice_type_code}` && - keep.has(nodeKey("node_post", assignment.post_id)), - ), + visibleVoiceRowIds.has(row.edge_id), ); const visibleIds = new Set([ ...nodes.map((node) => `lw:node/${node.node_type_code}/${node.node_id}`), @@ -217,9 +225,6 @@ export function filterNeighborhood( const visibleNodeIds = new Set(nodes.map( (node) => ontologyNodeId(node.node_type_code, node.node_id), )); - const visibleVoiceAssignments = (payload.voice_assignments ?? []).filter((assignment) => - keep.has(nodeKey("node_post", assignment.post_id)), - ); const visibleVoiceIds = new Set( visibleVoiceAssignments.flatMap((assignment) => [ assignment.voice_type_iri, @@ -237,7 +242,16 @@ export function filterNeighborhood( (visibleIds.has(item["@id"]) || visibleNodeIds.has(item["@id"]) || visibleVoiceIds.has(item["@id"])), - ), + ).map((item) => { + if (!Array.isArray(item[HAS_VOICE_ASSIGNMENT])) return item; + const relations = (item[HAS_VOICE_ASSIGNMENT] as unknown[]).filter( + (relation) => typeof relation === "object" && relation !== null && + "@id" in relation && typeof relation["@id"] === "string" && + visibleVoiceIds.has(relation["@id"]), + ); + const { [HAS_VOICE_ASSIGNMENT]: _omitted, ...other } = item; + return relations.length ? { ...other, [HAS_VOICE_ASSIGNMENT]: relations } : other; + }), } : payload.jsonld; return {