diff --git a/docs/product-technical-gap-baseline.md b/docs/product-technical-gap-baseline.md index 239e03da1..a8bfe9dd2 100644 --- a/docs/product-technical-gap-baseline.md +++ b/docs/product-technical-gap-baseline.md @@ -1,5 +1,219 @@ # Product & Technical Gap Baseline +## Current exact-head audit — 2026-09-30 18:31 KST + +This section supersedes older queue, validation, and owner-status claims. +Historical overlays remain supporting observations. Collection ended at +09:31:14 UTC; protected `main` was fetched as +`83eba56149eb802cd63642c507c324c9976ec78e`. No new LineageWeave protected +merge, deployment, or measured customer improvement is claimed. + +### Authority and implementation + +The current LineageWeave PRD was read before implementation work. ADR 0184 +governs neighborhood projections; ADR 0246 governs twelve atomic Voice +classifications; ADR 0252/0256 govern temporal assignments and extensible, +evidence-bearing combinations. ADR 0251 governs I/O psychology, not a finite +Voice-combination codebook. Parent #780 already reconciles that reference. +Research and standards cited by the ADRs establish semantics, not runtime +completion. No compound Voice code, B2B2C-only contract, psychological +crosswalk, weight, or local inference engine is introduced. A missing +accepted/persisted result stays unavailable. + +Remote metadata confirmed `ContextualWisdomLab/LineageWeave`, +`ContextualWisdomLab/RankWeave`, `ContextualWisdomLab/ThreadWeave`, +`ContextualWisdomLab/disksage`, and `ContextualWisdomLab/TEPP`. +`DiskSage` is the local checkout spelling, not the canonical remote name. +Authority reads: ThreadWeave `docs/PRD.md` at +`0fda6e60c2c80ec7b2aa2d58dac6b944dec6a6d0`; RankWeave `ARCHITECTURE.md` at +`32c29a07a4e33ab9c4c2847cef41ed265b9bcc41`; TEPP +`docs/product/prd-v0.4-approved.md` at `67b4fc15a835d007b4841941276207254b77480c`; +disksage `docs/PRD.md` at `58b4aece3e38a1cc7f7fa4b7fce73443344b2397`; +contextual-orchestrator `README.md` at +`50e1b0d0a7eddc0f866ab69162d6cf098efe693f`. These are local authority +revisions, not remote-default-branch equivalence claims. No ecosystem API or +implementation is changed. TEPP/fast-mlsirm retain measurement ownership; +all LLM/VISION/embedding/structured operations retain the orchestrator session +and bounded-provenance boundary. + +### Exact-head queue and protection + +Inventory: **175 open PRs**, **171 drafts**, **115 non-main bases**, and +**43 open issues**. All exact heads and thread pages were collected: +**95 unresolved threads**, no truncated thread page. Additional formal-review +pages for #667/#929/#983 were read to completion. Review text was treated as +an untrusted finding and checked against source; bot comments are not approvals. + +| PR | Exact observed remote head | Boundary | +| ---: | --- | --- | +| #1049 | `b1219365319712faaa4d3767243c8daf0e5b5ce0` | Ready, no unresolved thread; approval absent and Checks pending | +| #1131 | `ee3d8890ce3b7829f668e05732ef55d24e2e688e` | Ready; no unresolved thread or failure/pending rollup job; approval absent | +| #1135 | `b992923501e5123470d18dcc62263a0939f04f64` | Ready; previous occupation/calendar findings repaired; no unresolved thread; owner workflows pending | +| #1136 | `55f6992637c53cfb51a74f55987a40b359152bd5` | Ready; scope-race repair preserved; no unresolved thread; owner workflows pending | +| #780 | `1d8fa267b059289e77301a09985dfac70a439814` | Parent Draft; terminal CodeQL/Noema failures and no current-head approval | +| #968 | `ef6fcd7c2cfb15fd47d0a36af15d54d7f45373c9` | Existing child of #780, Draft; no unresolved thread; local follow-up below | + +Normal squash auto-merge was verified on #1049/#1131/#1135/#1136 at the final +queue observation. #1049's review automation disabled it at 09:27:06 UTC; +it was re-armed on the unchanged exact head through normal protection, without +manufacturing approval or status. Pending Checks did not stop product work. +Another agent's #1040/#1130 Draft decisions after terminal failures are +preserved. Their approvals do not override failed gates. No self-approval, +admin bypass, force push, gate weakening, or child-to-parent merge was used. + +CodeQL recovery owner `.github#1902` remains open/Draft, with requested changes +at `c8d7caa0d699cec0200815fdfbca8bc0b2f7a4ec`. +The earlier Noema owner `contextual-orchestrator#1094` is **merged**, at +`9334dc91aaf853b758077e983517a822b6b21edb` on 2026-09-09. That source merge +does not replace #780's missing successful consumer verdict. No local retry +policy, direct provider call, or substitute engine was added. Merge #780 +through protected `main` first; only then retarget #968 and collect new +exact-head Checks, threads, and independent approval. Auto-merge is not armed +against the child's unprotected parent branch. + +Running workflow inventory contained protected `main` and open #1135/#1136 +heads only. No runner-occupying closed/merged PR run was identified; none was +cancelled. Shared-queue recovery is not claimed. + +### Reused minimal repair and new evidence + +The selected remaining user-visible gap is loss of previously loaded +perspectives or record types in a paged download. Four synthetic assertions +reproduced singleton/array relation and RDF-type loss against fetched `main`. +The complete PR/file audit located the existing minimal repair in #968 and +overlapping #934. No duplicate PR was created. Other agents' source repair +and the parent's carrying/evidence distinction remain in their existing owner. +Validated local follow-up: `c5be337dce4b9b16adb3d7eb8a2fd4e3dc902a81`. +A following baseline-only commit changes the head and cannot inherit this +revision's hosted Checks or approval. + +- The existing repair unions ordinary property values and RDF types across + singleton/array representations while keeping subject identity scalar. + New regression preserves derivation references, labels, replay idempotency, + and input immutability. +- Frontend: **58 files / 538 tests passed**; lint, production typecheck/build, + and Storybook build passed. Synthetic ontology, SHACL, Voice/history, + authorization-batch, and public-docstring checks: **68 passed**, using the + frozen project-local backend environment and pinned Rust 1.97.1. Initial + dev-only collection lacked asyncpg; the declared backend extra resolved + that prerequisite. No warning was suppressed; the existing large-chunk + warning remains visible. +- Actual Chromium downloads from `PagedVoiceExport`, at **1440** and **390** + CSS pixels, retained two Voice relations, both RDF types, and the label. + CSV retained carrying/evidence identity columns and both Voice labels. + Two carrying actions and one additional-Voice derivation action stayed + distinct. No document overflow or page exception was observed; mobile + exact values retain the existing horizontal scroll region. Screenshots: + `docs/screenshots/voice-paging-desktop.png` and + `docs/screenshots/voice-paging-mobile.png`. + +The new story is a targeted page-union fixture, not a complete PostgreSQL +serialization or SHACL/runtime acceptance document. Search-bound hidden- +evidence removal remains independently owned by #1129 and was not copied +into this child. Local results do not complete authenticated API, +hidden-evidence, cutoff, or deployed-rendering acceptance. + +### Cross-PR risks and aggregate limits + +All 175 PR file inventories were paged without errors. Filename identities +yield **27 candidate overlap groups**: **16 ADR ordinals**, **3 migration +ordinals**, and **8 release-fragment versions**. These are review candidates, +not 27 proven semantic conflicts; inheritance and intentional same-release +fragments need separate review. Concrete migration risks: `0248` +(#1049 versus #929), `0249` (#1127 versus #1047), and multiple `0247` filenames +in #929. ADR `0245` names distinct decisions across #702 and #997/#1123. +Release `2.28.1` overlaps #1079/#1009. Do not renumber shipped migrations or +replace an ADR by ordinal alone. + +Layout edits overlap #780/#934/#935/#968/#1129. Baseline updates overlap many +branches, including #1131/#1135/#1136; retain their dated ownership evidence +during integration. This child adds no API route, schema, ADR ordinal, or +release identity. Whole-queue API semantic compatibility is not certified +by a shared-filename scan. + +Read-only SQL on official `lineageweave` PostgreSQL observed **43,189 Posts**, +**43,189 primary Voice rows**, **0 additional Voice rows**, **4 analysis runs**, +and **1 active / 3 total** connections at that instant. These are diagnostic +full-table counts, not population inference, API authorization proof, or +saturation measurements. No real record, identifier, credential, or raw +private response was saved as an artifact. + +Authenticated candidate PostgreSQL-to-API and deployed rendering remain +**unavailable**. Synthetic-only authenticated k6 concurrency, latency, error +rate, throughput, and PostgreSQL/worker/Valkey/gateway saturation remain +**unavailable**: the official runtime contains private records and no +synthetic-only authorization/data boundary was certified there. No private +corpus was load-tested, no guessed bottleneck was repaired, and no unsupported +population estimator was introduced. Compose remains `lineageweave`; no +formal volume or existing container was removed. + +## Bounded Voice export review — 2026-09-07 + +This observation is supporting evidence, not a live queue or architecture +authority. Earlier dated sections below remain historical. The inspected +protected main was `83eba56149eb802cd63642c507c324c9976ec78e`; the implementation +parent was #780 at `1d8fa267b059289e77301a09985dfac70a439814`. The follow-up must +remain stacked on #780 until that parent is protected-merged, then be retargeted +and revalidated. No parent test or approval transfers to the child. + +| Evidence class | Observation and limit | +| --- | --- | +| Product authority | Read `docs/product-requirements.md`, especially FR-2/FR-3 and release evidence. Read contextual-orchestrator `docs/product_planning.md` and `docs/architecture.md` for its external inference boundary; no upstream inference change is introduced. | +| Normative authority | ADR 0246 defines 12 governed atoms; ADR 0256 governs evidence-bearing combinations and page-property union. ADR 0251 is the I/O-Psychology layer in this tree, not the Voice-combination decision. | +| External standard | [W3C JSON-LD 1.1](https://www.w3.org/TR/json-ld11/#sets-and-lists) permits singleton values and unordered multiple values. This supports preserving supplied relations, not estimating new evidence or choosing truth. | +| Implementation gap | `accumulateNeighborhoodPages` unioned only two arrays. A singleton relation followed by an array, the reverse, or two different singleton relations lost earlier supplied evidence. A synthetic direct invocation reproduced the loss before repair. | +| Bounded repair | Normalize the two existing property values for union and retain a singleton when both inputs supply the same singleton. Preserve subject identity and input objects. No new Voice code, inference, API, schema, dependency, or release number. | +| Runtime evidence | Only service availability was inspected. No current private record aggregate, authenticated candidate PostgreSQL API acceptance, or candidate load/capacity artifact was collected. These acceptance criteria remain unverified. | + +The selected customer gap is export fidelity while accumulating evidence pages. +No measured impact ranking is available to establish it as the largest gap in +the entire product. The regression covers all three singleton/array directions, +replay idempotency, input immutability, and retained subject labels. It does not +establish authenticated authorization, cutoff, or persisted provenance acceptance. + +Candidate #968 implementation head +`82b7e71d84e51d1ca1c60431867849dd77de86fa`: the three direct Node regression +cases passed after failing before repair; lint passed. Vitest could not start +its process/thread pool before the startup timeout, including after frozen, +offline project-local dependency installation. No test cases executed in those +failed Vitest attempts. The production build did not reach a terminal result +during the bounded local audit. Storybook started, but browser evidence failed +with navigation/control-wait timeouts; no desktop/mobile screenshot acceptance +is claimed. Draft-policy skipped hosted Tests are not successful validation. +These limits leave #968 Draft and the product acceptance incomplete. + +The complete initial queue observation contained 125 open PRs: 118 Drafts and +97 targeting a branch other than main. A later GraphQL observation counted 126 +open PRs and 19 open issues; these are separate observations, not one atomic +snapshot. Ready heads independently inspected in this review: + +| PR | Exact head | Observed protection state | +| --- | --- | --- | +| #961 | `3bdec0504a65e63f44bd49ba15de37182a1672cc` | Checks rollup failure; review required; auto-merge retained | +| #929 | `2a8ed5d02f4a3082b346d923d754c1ff37ebff52` | Checks rollup failure; review required; auto-merge retained | +| #914 | `61ed3a3712d252e3c179a71d297c52f05e1bac20` | Checks rollup failure; review required; auto-merge retained | +| #911 | `5d40eed35a0b6e0d182397f8d02b29c38e9bdd17` | Checks rollup failure; review required; auto-merge retained | +| #802 | `32f1cda10a2a1a6cabd64a3ae6f59bd6f0b20fd6` | Checks rollup failure; review required; auto-merge retained | +| #780 | `1d8fa267b059289e77301a09985dfac70a439814` | Tests passed but central CodeQL shards failed; no qualifying approval established | +| #964 | `da29d2cd38178b0aa57a9701972fdccfd4f9d03c` | Pending/running Checks; predecessor review text is not current-head acceptance | + +The first five PRs had zero unresolved review threads in the fetched first-100 +thread connection. GraphQL ruleset observation found active central required +workflows, one required approval, required thread resolution, and no-force-push +protection; last-push approval was false. REST was rate-limited, so no bypass or +merge decision was attempted. No merge SHA is claimed. + +Conflict audit: the protected main tree had no duplicate ADR filename numbers. +The initial PR titles repeated release 2.61.0 (#841/#842), 2.62.0 (#843/#844), +and 2.92.0 (#876/#877). Title collisions require owner reconciliation before +release; they do not prove conflicting file contents. The new bounded repair +does not alter API, migration, ADR identity, or version files. A complete +cross-PR schema/API diff audit remains outstanding. + +Remote repository metadata confirmed `ContextualWisdomLab/LineageWeave`, +`RankWeave`, `ThreadWeave`, `TEPP`, and `contextual-orchestrator`; the canonical +storage repository is `ContextualWisdomLab/disksage`, not `DiskSage`. + > Exact-head development-loop snapshot: 2026-09-02 KST. Protected `main` is > `3f61c8242b9c02dec307a7396e83e28f7cdd9f3d`; the fresh inventory contains > 107 open PRs and 15 open non-PR issues. PR #780's remotely observed evidence diff --git a/docs/screenshots/voice-paging-desktop.png b/docs/screenshots/voice-paging-desktop.png new file mode 100644 index 000000000..775593722 Binary files /dev/null and b/docs/screenshots/voice-paging-desktop.png differ diff --git a/docs/screenshots/voice-paging-mobile.png b/docs/screenshots/voice-paging-mobile.png new file mode 100644 index 000000000..bc8e98446 Binary files /dev/null and b/docs/screenshots/voice-paging-mobile.png differ diff --git a/docs/storybook-inventory.md b/docs/storybook-inventory.md index 62c5f9cf3..2d5ffe247 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/0256 states. The audited combined-Voice renders are `docs/screenshots/voice-combination-export-desktop.png` and `docs/screenshots/voice-combination-export-mobile.png`. | `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. `PagedVoiceExport` exercises downloaded singleton/array Voice relations and RDF types after page accumulation. Desktop, narrow, drawers, legend/filter, empty, truncated, partial, denied, stale, and rejected scenes cover ADR 0184/0222/0256 states. The audited combined-Voice renders are `docs/screenshots/voice-combination-export-desktop.png` and `docs/screenshots/voice-combination-export-mobile.png`; paged-export renders are `docs/screenshots/voice-paging-desktop.png` and `docs/screenshots/voice-paging-mobile.png`. | `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..8259d0938 100644 --- a/frontend/src/components/OntologyExplorer.stories.tsx +++ b/frontend/src/components/OntologyExplorer.stories.tsx @@ -1,5 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import type { OntologyNeighborhoodPayload } from "../api"; +import { accumulateNeighborhoodPages } from "../ontologyLayout"; import { OntologyExplorer } from "./OntologyExplorer"; const POST_ID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1"; @@ -331,6 +332,36 @@ export const CombinedVoiceEvidence: Story = { }, }; +export const PagedVoiceExport: Story = { + args: { + neighborhood: accumulateNeighborhoodPages( + { + ...combinedVoiceNeighborhood, + jsonld: { + ...combinedVoiceNeighborhood.jsonld, + "@graph": [{ + "@id": `https://contextualwisdomlab.github.io/LineageWeave/ontology#node/node_post/${POST_ID}`, + "@type": "https://contextualwisdomlab.github.io/LineageWeave/ontology#Post", + "http://www.w3.org/2000/01/rdf-schema#label": "Demo public post", + "lw:hasVoiceAssignment": { "@id": `lw:voice-assignment/${POST_ID}/voc` }, + }], + }, + }, + { + ...combinedVoiceNeighborhood, + jsonld: { + ...combinedVoiceNeighborhood.jsonld, + "@graph": [{ + "@id": `https://contextualwisdomlab.github.io/LineageWeave/ontology#node/node_post/${POST_ID}`, + "@type": ["http://www.w3.org/ns/prov#Entity"], + "lw:hasVoiceAssignment": [{ "@id": `lw:voice-assignment/${POST_ID}/vops` }], + }], + }, + }, + ), + }, +}; + export const LongLabelsAndEvidenceTable: Story = { args: { neighborhood: { diff --git a/frontend/src/ontologyLayout.test.ts b/frontend/src/ontologyLayout.test.ts index 1458436de..4284e2d1c 100644 --- a/frontend/src/ontologyLayout.test.ts +++ b/frontend/src/ontologyLayout.test.ts @@ -260,6 +260,68 @@ describe("ontologyLayout", () => { ]); }); + it.each([ + [{ "@id": "voice:one" }, [{ "@id": "voice:two" }]], + [[{ "@id": "voice:one" }], { "@id": "voice:two" }], + [{ "@id": "voice:one" }, { "@id": "voice:two" }], + ])("retains singleton and array Voice relations across pages (%j, %j)", (firstValue, nextValue) => { + const source = payload(); + const postIri = `${ONTOLOGY_NAMESPACE}node/node_post/${POST_ID}`; + const propertyIri = `${ONTOLOGY_NAMESPACE}hasVoiceAssignment`; + const first = { + ...source, + jsonld: { "@graph": [{ "@id": postIri, "rdfs:label": "Demo public post", [propertyIri]: firstValue }] }, + }; + const second = { + ...source, + jsonld: { "@graph": [{ "@id": postIri, [propertyIri]: nextValue }] }, + }; + const original = JSON.stringify([first, second]); + const merged = accumulateNeighborhoodPages(first, second); + expect(merged.jsonld["@graph"]).toEqual([{ + "@id": postIri, + "rdfs:label": "Demo public post", + [propertyIri]: [{ "@id": "voice:one" }, { "@id": "voice:two" }], + }]); + expect(accumulateNeighborhoodPages(merged, second).jsonld).toEqual(merged.jsonld); + expect(JSON.stringify([first, second])).toBe(original); + }); + + it("preserves RDF types and derivation evidence without duplicating a replayed page", () => { + const source = payload(); + const assignmentIri = `${ONTOLOGY_NAMESPACE}voice-assignment/${POST_ID}/voc`; + const derivedFromIri = "http://www.w3.org/ns/prov#wasDerivedFrom"; + const first = { + ...source, + jsonld: { "@graph": [{ + "@id": assignmentIri, + "@type": `${ONTOLOGY_NAMESPACE}VoiceAssignment`, + [derivedFromIri]: { "@id": "evidence:one" }, + "rdfs:label": "Customer perspective", + }] }, + }; + const next = { + ...source, + jsonld: { "@graph": [{ + "@id": assignmentIri, + "@type": ["http://www.w3.org/ns/prov#Entity"], + [derivedFromIri]: [{ "@id": "evidence:one" }, { "@id": "evidence:two" }], + "rdfs:label": "Customer perspective", + }] }, + }; + const original = JSON.stringify([first, next]); + const accumulated = accumulateNeighborhoodPages(first, next); + + expect(accumulated.jsonld["@graph"]).toEqual([{ + "@id": assignmentIri, + "@type": [`${ONTOLOGY_NAMESPACE}VoiceAssignment`, "http://www.w3.org/ns/prov#Entity"], + [derivedFromIri]: [{ "@id": "evidence:one" }, { "@id": "evidence:two" }], + "rdfs:label": "Customer perspective", + }]); + expect(accumulateNeighborhoodPages(accumulated, next).jsonld).toEqual(accumulated.jsonld); + expect(JSON.stringify([first, next])).toBe(original); + }); + it("keeps only exact canonical JSON-LD node ids when filtering", () => { 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 f7092168f..aa7ac6ccf 100644 --- a/frontend/src/ontologyLayout.ts +++ b/frontend/src/ontologyLayout.ts @@ -317,15 +317,23 @@ export function accumulateNeighborhoodPages( } const merged = { ...existing, ...incoming }; for (const key of Object.keys(incoming)) { - if (Array.isArray(existing[key]) && Array.isArray(incoming[key])) { - const values = [...existing[key], ...incoming[key]]; + if (Object.hasOwn(existing, key) && (!key.startsWith("@") || key === "@type")) { + const previous = existing[key]; + const added = incoming[key]; + const values = [ + ...(Array.isArray(previous) ? previous : [previous]), + ...(Array.isArray(added) ? added : [added]), + ]; const seen = new Set(); - merged[key] = values.filter((value) => { + const unique = values.filter((value) => { const serialized = JSON.stringify(value); if (seen.has(serialized)) return false; seen.add(serialized); return true; }); + merged[key] = unique.length === 1 && !Array.isArray(previous) && !Array.isArray(added) + ? unique[0] + : unique; } } graphItems.set(item["@id"], merged);