Skip to content

Commit ba22294

Browse files
neo-opus-adatobiuAda (Claude Opus 4.8)
authored
fix(ingestion): route AdrIngestor CODIFIES_CONCEPT through the concept-spine SSOT + document the dedupe divergence (#14540) (#14787)
Consumer-sweep leaf of #14472 (concept-id SSOT), from my own #14528 review where I grepped every (CONCEPT|CLASS| PROCESS): site in ai/ and found two deterministic consumers un-routed: - AdrIngestor.parseEdges: CODIFIES_CONCEPT edges keyed the RAW captured ref (GoldenPath), so ADR->concept edges pointed at a different node than the session/mailbox mints (golden-path), splitting the ADR->concept graph axis. Now routes match[1] through canonicalizeConceptId (import-and-delegate, the #14528 pattern), with a raw-ref fallback so an empty canonicalization never drops the edge (Contract Ledger). Unit spec: CONCEPT:GoldenPath -> edge target golden-path, not GoldenPath. - ConceptDiscoveryService.normalizeConceptNameForDedupe: an intentional-divergence docblock, NOT delegation. V-B-A of the code: it strips a leading 'The ' and drops separators (dash-less), a looser fold than the SSOT; delegating would SPLIT the 'The X'/'X' candidate group. It keys internal mining candidates only (never a graph id), so per the one-vocabulary-per-contract invariant (ADR-0031 #8) it is documented divergence-by-purpose, not a re-derived copy of the graph vocabulary. AC3 verified: grep for prefix-strip logic outside the SSOT returns only the now-documented dedupe function. 24 ingestion specs green. (The #14528 review nits are optional per AC4; deferred to keep this leaf's diff on the consumer sweep.) Co-authored-by: tobiu <tobiasuhlig78@gmail.com> Co-authored-by: Ada (Claude Opus 4.8) <ada@neomjs.dev>
1 parent 2f3a85d commit ba22294

3 files changed

Lines changed: 42 additions & 15 deletions

File tree

ai/services/ingestion/AdrIngestor.mjs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import path
44
import Base from '../../../src/core/Base.mjs';
55
import {Memory_GraphService as GraphService, Memory_StorageRouter as StorageRouter} from '../../services.mjs';
66
import logger from '../../mcp/server/memory-core/logger.mjs';
7+
import {canonicalizeConceptId} from '../graph/conceptSpineCanonicalization.mjs';
78

89
const
910
ADR_EDGE_TYPES = Object.freeze({
@@ -179,7 +180,10 @@ class AdrIngestor extends Base {
179180
}
180181

181182
for (const match of content.matchAll(CONCEPT_REF_REGEX)) {
182-
addEdge(adrId, match[1], ADR_EDGE_TYPES.CODIFIES_CONCEPT);
183+
// Route through the concept-spine SSOT so ADR CODIFIES_CONCEPT edges target the same canonical
184+
// node the session/mailbox mints do (`GoldenPath` → `golden-path`); keep the raw ref if the
185+
// canonicalization comes back empty — never drop the edge (Contract Ledger fallback).
186+
addEdge(adrId, canonicalizeConceptId(match[1]) || match[1], ADR_EDGE_TYPES.CODIFIES_CONCEPT);
183187
}
184188

185189
return Array.from(edges.values());

ai/services/ingestion/ConceptDiscoveryService.mjs

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,18 @@ const PROCESS_MX_CANDIDATE_DEFAULTS = {
9494
codeGapEligible: false
9595
};
9696

97+
/**
98+
* @summary Internal dedupe key for mined concept CANDIDATES — deliberately NOT the graph-vocabulary SSOT
99+
* (`conceptSpineCanonicalization.normalizeConceptKey`). It keys mining candidates only, never a graph id, and
100+
* carries dedupe-specific heuristics the spine intentionally omits: a leading `The ` is stripped (so "The
101+
* Golden Path" and "Golden Path" collapse to one candidate) and separators are dropped entirely (dash-less), a
102+
* looser fold that groups candidates more aggressively than the graph vocabulary. Routing this through the
103+
* SSOT would SPLIT those candidate groups (the spine keeps `the-` and the dash) — so this is documented
104+
* divergence-by-purpose under the one-vocabulary-per-contract invariant, NOT a re-derived copy of the graph
105+
* vocabulary that the invariant forbids.
106+
* @param {String} value Raw candidate concept name.
107+
* @returns {String} Internal dedupe key (never a graph id).
108+
*/
97109
function normalizeConceptNameForDedupe(value) {
98110
if (typeof value !== 'string') return '';
99111

@@ -403,12 +415,12 @@ class ConceptDiscoveryService extends Base {
403415
if (this.isKnownConceptCandidate(raw, knownConceptNameKeys)) continue;
404416

405417
accepted.push({
406-
id : raw.id,
407-
name: raw.name,
418+
id : raw.id,
419+
name : raw.name,
408420
description: raw.description || `Mined candidate from ${sourceRef}. Awaiting curator review — promote by flipping \`validated: true\` and adjusting tier/weight in nodes.jsonl.`,
409-
aliases : Array.isArray(raw.aliases) ? raw.aliases : [],
410-
source : sourceRef,
411-
reasoning: raw.reasoning || '',
421+
aliases : Array.isArray(raw.aliases) ? raw.aliases : [],
422+
source : sourceRef,
423+
reasoning : raw.reasoning || '',
412424
...candidateDefaults,
413425
...(extractionMetadata ? {extraction_metadata: extractionMetadata} : {})
414426
});

test/playwright/unit/ai/services/ingestion/AdrIngestor.spec.mjs

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,12 @@ setup({
1313
}
1414
});
1515

16-
import {test, expect} from '@playwright/test';
17-
import Neo from '../../../../../../src/Neo.mjs';
18-
import * as core from '../../../../../../src/core/_export.mjs';
19-
import fs from 'fs';
20-
import path from 'path';
21-
import os from 'os';
16+
import {test, expect} from '@playwright/test';
17+
import Neo from '../../../../../../src/Neo.mjs';
18+
import * as core from '../../../../../../src/core/_export.mjs';
19+
import fs from 'fs';
20+
import path from 'path';
21+
import os from 'os';
2222
import {TestLifecycleHelper} from '../../services/memory-core/util.mjs';
2323

2424
test.describe('Neo.ai.daemons.services.AdrIngestor', () => {
@@ -30,7 +30,7 @@ test.describe('Neo.ai.daemons.services.AdrIngestor', () => {
3030
let tmpRoot;
3131
let decisionsDir;
3232
let StorageRouter;
33-
let upsertedDocs = [];
33+
let upsertedDocs = [];
3434
let embeddedStore = new Map();
3535
let _originalGetGraphCollection;
3636

@@ -274,9 +274,9 @@ Completely different body content.
274274
Ticket #456
275275
`);
276276

277-
const first = await AdrIngestor.syncAdrsToGraph(syncOptions());
277+
const first = await AdrIngestor.syncAdrsToGraph(syncOptions());
278278
const edgesAfterFirst = GraphService.db.edges.items.length;
279-
const second = await AdrIngestor.syncAdrsToGraph(syncOptions());
279+
const second = await AdrIngestor.syncAdrsToGraph(syncOptions());
280280

281281
expect(first.adrsUpserted).toBe(1);
282282
expect(second.adrsUpserted).toBe(0);
@@ -285,6 +285,17 @@ Ticket #456
285285
expect(GraphService.db.edges.items.length).toBe(edgesAfterFirst);
286286
});
287287

288+
test('CODIFIES_CONCEPT edge targets canonicalize through the concept-spine SSOT — PascalCase ref → kebab node (#14540)', () => {
289+
const edges = AdrIngestor.parseEdges('adr-0026', 'Codifies CONCEPT:GoldenPath and CONCEPT:mx-loop.'),
290+
codifies = edges.filter(edge => edge.type === 'CODIFIES_CONCEPT').map(edge => edge.target);
291+
292+
// GoldenPath → the canonical kebab node the session/mailbox mints also target, not the raw PascalCase ref
293+
expect(codifies).toContain('golden-path');
294+
expect(codifies).not.toContain('GoldenPath');
295+
// an already-canonical ref passes through unchanged
296+
expect(codifies).toContain('mx-loop');
297+
});
298+
288299
test('should isolate malformed files as errors while ingesting valid ADRs', async () => {
289300
writeAdr('0099-valid.md', `
290301
# ADR 0099: Valid

0 commit comments

Comments
 (0)