@unson/brainbase-mcp 0.2.4 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -12
- package/dist/canonical-edge-builder.d.ts +11 -0
- package/dist/canonical-edge-builder.js +35 -0
- package/dist/canonical-graph.d.ts +9 -0
- package/dist/canonical-graph.js +174 -0
- package/dist/cli.js +134 -32
- package/dist/connected-onboarding.js +45 -11
- package/dist/entity-resolution.d.ts +111 -0
- package/dist/entity-resolution.js +381 -0
- package/dist/graph-diagnosis.d.ts +24 -0
- package/dist/graph-diagnosis.js +129 -0
- package/dist/guided-onboarding.js +4 -4
- package/dist/import-extract.d.ts +8 -3
- package/dist/import-extract.js +142 -17
- package/dist/onboarding.js +5 -5
- package/dist/ontology-migration.d.ts +30 -0
- package/dist/ontology-migration.js +339 -0
- package/dist/ontology-ssot.js +2 -2
- package/dist/ontology.d.ts +101 -3
- package/dist/ontology.js +64 -8
- package/dist/operationalization.js +6 -6
- package/dist/projects.d.ts +3 -1
- package/dist/projects.js +40 -0
- package/dist/relation-registry.d.ts +11 -0
- package/dist/relation-registry.js +66 -0
- package/dist/server.d.ts +86 -2
- package/dist/server.js +158 -9
- package/dist/skills.js +3 -3
- package/dist/ssot.d.ts +16 -0
- package/dist/ssot.js +68 -18
- package/dist/templates.d.ts +110 -2
- package/dist/templates.js +74 -5
- package/dist/tools.d.ts +6 -2
- package/dist/tools.js +184 -147
- package/dist/types.d.ts +52 -1
- package/package.json +4 -3
|
@@ -3,6 +3,7 @@ import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
|
3
3
|
import { hostname } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { planApply } from './import-extract.js';
|
|
6
|
+
import { applyCanonicalWrites } from './canonical-edge-builder.js';
|
|
6
7
|
import { initializePersonalOs, loadPersonalOs, mutatePersonalOsWithSidecar } from './ssot.js';
|
|
7
8
|
const LEDGER_SCHEMA_VERSION = 'connected_onboarding.v1';
|
|
8
9
|
const SHA256 = /^sha256:[a-f0-9]{64}$/;
|
|
@@ -136,6 +137,9 @@ export class ConnectedOnboardingRuntime {
|
|
|
136
137
|
return withLedgerLock(this.dataDir, async () => {
|
|
137
138
|
const ledger = await loadLedger(this.dataDir);
|
|
138
139
|
return mutatePersonalOsWithSidecar(this.dataDir, 'runs/connected-onboarding.json', (os) => {
|
|
140
|
+
if (os.graph.version !== 2) {
|
|
141
|
+
throw new ConnectedOnboardingError('migration_required', 'Graph v1 cannot store canonical ID edges; migrate graph.json to Graph v2 before writing');
|
|
142
|
+
}
|
|
139
143
|
assertLedgerCanonicalReferences(ledger, os);
|
|
140
144
|
const run = requireRun(ledger, runId);
|
|
141
145
|
const prepared = prepareReview(run, actions);
|
|
@@ -144,10 +148,12 @@ export class ConnectedOnboardingRuntime {
|
|
|
144
148
|
}
|
|
145
149
|
const result = planApply(prepared.promotions, { ids: new Set(), all: true }, {
|
|
146
150
|
graphEntities: [...os.graph.entities],
|
|
151
|
+
graphEdges: [...os.graph.edges],
|
|
147
152
|
relationships: [...os.relationships.relationships],
|
|
148
153
|
personalKg: os.personalKg,
|
|
149
154
|
decisions: os.decisions,
|
|
150
|
-
ownerName: os.graph.owner?.name
|
|
155
|
+
ownerName: os.graph.owner?.name,
|
|
156
|
+
provenanceSourceKind: 'onboarding'
|
|
151
157
|
}, this.timestamp());
|
|
152
158
|
if (result.skipped.length > 0 || result.applied.length !== prepared.promotions.length) {
|
|
153
159
|
throw new ConnectedOnboardingError('candidate_not_promotable', result.skipped[0]?.reason ?? 'candidate was not promoted');
|
|
@@ -165,13 +171,13 @@ export class ConnectedOnboardingRuntime {
|
|
|
165
171
|
run.state = advanceState(run.state, 'promotion_reviewed');
|
|
166
172
|
run.updatedAt = this.timestamp();
|
|
167
173
|
const sidecarContent = serializeLedger(ledger);
|
|
174
|
+
const graph = applyCanonicalWrites(os.graph, result.canonicalWrites);
|
|
168
175
|
return {
|
|
169
176
|
next: {
|
|
170
177
|
...os,
|
|
171
178
|
graph: {
|
|
172
|
-
...
|
|
173
|
-
owner: result.ownerName ? { ...
|
|
174
|
-
entities: result.graphEntities
|
|
179
|
+
...graph,
|
|
180
|
+
owner: result.ownerName ? { ...graph.owner, name: result.ownerName } : graph.owner
|
|
175
181
|
},
|
|
176
182
|
relationships: { version: 1, relationships: result.relationships },
|
|
177
183
|
personalKg: mergeById(os.personalKg, result.personalKgAdditions),
|
|
@@ -518,9 +524,8 @@ function isSameTerminalReview(candidate, action, reason) {
|
|
|
518
524
|
return action.payload === undefined && action.mergeIntoCandidateId === undefined;
|
|
519
525
|
}
|
|
520
526
|
function assertPromotable(candidate) {
|
|
521
|
-
const
|
|
522
|
-
|
|
523
|
-
}, '2000-01-01T00:00:00.000Z');
|
|
527
|
+
const validation = candidateValidationContext(candidate, true);
|
|
528
|
+
const result = planApply([validation.candidate], { ids: new Set(), all: true }, validation.base, '2000-01-01T00:00:00.000Z');
|
|
524
529
|
if (result.skipped.length > 0 || result.applied.length !== 1) {
|
|
525
530
|
throw new ConnectedOnboardingError('candidate_not_promotable', result.skipped[0]?.reason ?? 'candidate is not promotable');
|
|
526
531
|
}
|
|
@@ -787,14 +792,42 @@ function isValidLedgerCandidate(value, runId, sourceIds) {
|
|
|
787
792
|
|| value.mergedIntoCandidateId !== undefined
|
|
788
793
|
|| value.promotedCanonicalIds.length === 0)
|
|
789
794
|
return false;
|
|
790
|
-
return stableJson(value.promotedCanonicalIds) === stableJson(expectedCanonicalIds(
|
|
795
|
+
return stableJson(value.promotedCanonicalIds) === stableJson(expectedCanonicalIds({
|
|
796
|
+
id: String(value.id),
|
|
797
|
+
kind: String(value.kind),
|
|
798
|
+
payload: value.payload
|
|
799
|
+
}));
|
|
791
800
|
}
|
|
792
801
|
function expectedCanonicalIds(candidate) {
|
|
793
|
-
const
|
|
794
|
-
|
|
795
|
-
}, '2000-01-01T00:00:00.000Z');
|
|
802
|
+
const validation = candidateValidationContext(candidate, false);
|
|
803
|
+
const result = planApply([validation.candidate], { ids: new Set(), all: true }, validation.base, '2000-01-01T00:00:00.000Z');
|
|
796
804
|
return result.skipped.length === 0 && result.applied.length === 1 ? result.applied[0].canonicalIds : [];
|
|
797
805
|
}
|
|
806
|
+
function candidateValidationContext(candidate, fillMissingProject) {
|
|
807
|
+
const payload = { ...candidate.payload };
|
|
808
|
+
const requiresProject = ['person', 'relationship', 'decision'].includes(candidate.kind);
|
|
809
|
+
const explicitProjectId = typeof payload.projectId === 'string' ? payload.projectId.trim() : '';
|
|
810
|
+
const projectId = explicitProjectId || (requiresProject && fillMissingProject ? 'project-validation-only' : '');
|
|
811
|
+
if (projectId && !explicitProjectId)
|
|
812
|
+
payload.projectId = projectId;
|
|
813
|
+
const supersedes = Array.isArray(payload.supersedes)
|
|
814
|
+
? payload.supersedes.filter((id) => typeof id === 'string' && id.trim().length > 0)
|
|
815
|
+
: [];
|
|
816
|
+
return {
|
|
817
|
+
candidate: { id: candidate.id, kind: candidate.kind, payload },
|
|
818
|
+
base: {
|
|
819
|
+
graphEntities: [
|
|
820
|
+
...(projectId ? [{ id: projectId, type: 'project', name: projectId }] : []),
|
|
821
|
+
...supersedes.map((id) => ({ id, type: 'decision', name: id }))
|
|
822
|
+
],
|
|
823
|
+
graphEdges: [],
|
|
824
|
+
relationships: [],
|
|
825
|
+
personalKg: [],
|
|
826
|
+
decisions: [],
|
|
827
|
+
provenanceSourceKind: 'onboarding'
|
|
828
|
+
}
|
|
829
|
+
};
|
|
830
|
+
}
|
|
798
831
|
function isValidFirstValueReceipt(value, promoted) {
|
|
799
832
|
if (value === undefined)
|
|
800
833
|
return true;
|
|
@@ -820,6 +853,7 @@ function isValidFirstValueReview(value, hasReceipt) {
|
|
|
820
853
|
function assertCanonicalReferences(run, os) {
|
|
821
854
|
const canonicalIds = new Set([
|
|
822
855
|
...os.graph.entities.map((item) => item.id),
|
|
856
|
+
...(os.graph.version === 2 ? os.graph.edges.map((item) => item.id) : []),
|
|
823
857
|
...os.relationships.relationships.map((item) => item.id),
|
|
824
858
|
...os.personalKg.map((item) => item.id),
|
|
825
859
|
...os.decisions.map((item) => item.id)
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { CanonicalEntityKind, GraphFileV2 } from './types.js';
|
|
2
|
+
export type SourceStatus = 'complete' | 'partial' | 'unavailable' | 'invalid';
|
|
3
|
+
export type MentionStatus = 'resolved' | 'ambiguous' | 'unresolved';
|
|
4
|
+
export type ResolutionStatus = 'complete' | 'partial' | 'none' | 'blocked';
|
|
5
|
+
export type ScopePolicy = 'strict' | 'prefer_project' | 'allow_global_fallback';
|
|
6
|
+
export interface ResolutionSource {
|
|
7
|
+
authority: 'local_graph';
|
|
8
|
+
status: SourceStatus;
|
|
9
|
+
revision?: string;
|
|
10
|
+
issues?: Array<{
|
|
11
|
+
code: string;
|
|
12
|
+
message: string;
|
|
13
|
+
}>;
|
|
14
|
+
graph?: GraphFileV2;
|
|
15
|
+
}
|
|
16
|
+
export interface ResolveTextInput {
|
|
17
|
+
text: string;
|
|
18
|
+
mentionSpans?: Array<{
|
|
19
|
+
start: number;
|
|
20
|
+
end: number;
|
|
21
|
+
}>;
|
|
22
|
+
projectScope?: {
|
|
23
|
+
projectIds: string[];
|
|
24
|
+
policy?: ScopePolicy;
|
|
25
|
+
};
|
|
26
|
+
asOf: string;
|
|
27
|
+
entityTypes?: CanonicalEntityKind[];
|
|
28
|
+
source: ResolutionSource;
|
|
29
|
+
}
|
|
30
|
+
export interface CandidateEvidence {
|
|
31
|
+
kind: 'name_exact' | 'alias_exact' | 'honorific_variant' | 'project_scope' | 'relation_path' | 'valid_at';
|
|
32
|
+
projectId?: string;
|
|
33
|
+
edgeId?: string;
|
|
34
|
+
edgeIds?: string[];
|
|
35
|
+
asOf?: string;
|
|
36
|
+
}
|
|
37
|
+
export interface ResolutionCandidate {
|
|
38
|
+
entityId: string;
|
|
39
|
+
score: number;
|
|
40
|
+
evidence: CandidateEvidence[];
|
|
41
|
+
}
|
|
42
|
+
export interface MentionResolution {
|
|
43
|
+
span: {
|
|
44
|
+
start: number;
|
|
45
|
+
end: number;
|
|
46
|
+
};
|
|
47
|
+
surface?: string;
|
|
48
|
+
surfaceHash: string;
|
|
49
|
+
normalized: string;
|
|
50
|
+
status: MentionStatus;
|
|
51
|
+
selectedEntityId?: string;
|
|
52
|
+
candidates: ResolutionCandidate[];
|
|
53
|
+
}
|
|
54
|
+
export type PortableMentionResolution = Omit<MentionResolution, 'surface' | 'normalized'>;
|
|
55
|
+
export interface ResolutionReceiptV1 {
|
|
56
|
+
schemaVersion: 1;
|
|
57
|
+
resolverVersion: string;
|
|
58
|
+
graphSchemaVersion: 2 | null;
|
|
59
|
+
ontology: {
|
|
60
|
+
id: string;
|
|
61
|
+
version: string;
|
|
62
|
+
releaseDigest: string;
|
|
63
|
+
} | null;
|
|
64
|
+
request: {
|
|
65
|
+
textSha256: string;
|
|
66
|
+
textLength: number;
|
|
67
|
+
projectScope?: {
|
|
68
|
+
projectIds: string[];
|
|
69
|
+
policy: ScopePolicy;
|
|
70
|
+
};
|
|
71
|
+
asOf: string;
|
|
72
|
+
entityTypes?: CanonicalEntityKind[];
|
|
73
|
+
};
|
|
74
|
+
source: {
|
|
75
|
+
authority: 'local_graph';
|
|
76
|
+
status: SourceStatus;
|
|
77
|
+
revisionSha256?: string;
|
|
78
|
+
issueCodes: string[];
|
|
79
|
+
};
|
|
80
|
+
resolutionStatus: ResolutionStatus;
|
|
81
|
+
mentions: PortableMentionResolution[];
|
|
82
|
+
summary: {
|
|
83
|
+
resolved: number;
|
|
84
|
+
ambiguous: number;
|
|
85
|
+
unresolved: number;
|
|
86
|
+
} | null;
|
|
87
|
+
digest: string;
|
|
88
|
+
}
|
|
89
|
+
export declare const resolverVersion = "1.0.0";
|
|
90
|
+
export declare function resolveText(input: ResolveTextInput): {
|
|
91
|
+
mentions: MentionResolution[];
|
|
92
|
+
receipt: ResolutionReceiptV1;
|
|
93
|
+
};
|
|
94
|
+
export declare function verifyResolutionReceipt(receipt: ResolutionReceiptV1, expected: {
|
|
95
|
+
text: string;
|
|
96
|
+
asOf: string;
|
|
97
|
+
sourceStatus: SourceStatus;
|
|
98
|
+
sourceRevision?: string;
|
|
99
|
+
sourceIssueCodes?: string[];
|
|
100
|
+
graph?: GraphFileV2;
|
|
101
|
+
mentionSpans?: Array<{
|
|
102
|
+
start: number;
|
|
103
|
+
end: number;
|
|
104
|
+
}>;
|
|
105
|
+
projectScope?: {
|
|
106
|
+
projectIds: string[];
|
|
107
|
+
policy?: ScopePolicy;
|
|
108
|
+
};
|
|
109
|
+
entityTypes?: CanonicalEntityKind[];
|
|
110
|
+
}): boolean;
|
|
111
|
+
export declare function normalizeResolverText(value: string): string;
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { isActiveAt, validateCanonicalGraph } from './canonical-graph.js';
|
|
3
|
+
import { getCanonicalRelation } from './relation-registry.js';
|
|
4
|
+
export const resolverVersion = '1.0.0';
|
|
5
|
+
export function resolveText(input) {
|
|
6
|
+
assertResolveTextInput(input);
|
|
7
|
+
const asOf = input.asOf;
|
|
8
|
+
isActiveAt({}, asOf);
|
|
9
|
+
const projectScope = input.projectScope
|
|
10
|
+
? { projectIds: [...new Set(input.projectScope.projectIds)].sort(), policy: input.projectScope.policy ?? 'strict' }
|
|
11
|
+
: undefined;
|
|
12
|
+
if (input.source.status === 'unavailable' || input.source.status === 'invalid') {
|
|
13
|
+
return finalize(input, asOf, projectScope, [], 'blocked', null);
|
|
14
|
+
}
|
|
15
|
+
if (!input.source.graph) {
|
|
16
|
+
throw new Error(`RESOLUTION-SOURCE-GRAPH-REQUIRED: source status ${input.source.status} requires a Graph snapshot`);
|
|
17
|
+
}
|
|
18
|
+
validateCanonicalGraph(input.source.graph);
|
|
19
|
+
const graph = input.source.graph;
|
|
20
|
+
const allowedTypes = input.entityTypes ? new Set(input.entityTypes) : undefined;
|
|
21
|
+
const entities = graph.entities
|
|
22
|
+
.filter((entity) => !allowedTypes || allowedTypes.has(entity.type))
|
|
23
|
+
.filter((entity) => isActiveAt(entity, asOf));
|
|
24
|
+
const mentions = collectMentions(input.text, input.mentionSpans ?? [], entities, graph, asOf, projectScope);
|
|
25
|
+
const summary = {
|
|
26
|
+
resolved: mentions.filter((mention) => mention.status === 'resolved').length,
|
|
27
|
+
ambiguous: mentions.filter((mention) => mention.status === 'ambiguous').length,
|
|
28
|
+
unresolved: mentions.filter((mention) => mention.status === 'unresolved').length
|
|
29
|
+
};
|
|
30
|
+
const status = mentions.length === 0 || summary.resolved === 0
|
|
31
|
+
? 'none'
|
|
32
|
+
: summary.ambiguous + summary.unresolved === 0 && input.source.status === 'complete'
|
|
33
|
+
? 'complete'
|
|
34
|
+
: 'partial';
|
|
35
|
+
return finalize(input, asOf, projectScope, mentions, status, summary);
|
|
36
|
+
}
|
|
37
|
+
function collectMentions(text, requestedSpans, entities, graph, asOf, scope) {
|
|
38
|
+
const bySpan = new Map();
|
|
39
|
+
for (const span of requestedSpans) {
|
|
40
|
+
if (!Number.isInteger(span.start) || !Number.isInteger(span.end) || span.start < 0 || span.end <= span.start || span.end > text.length) {
|
|
41
|
+
throw new Error(`Invalid mention span: ${span.start}:${span.end}`);
|
|
42
|
+
}
|
|
43
|
+
const surface = text.slice(span.start, span.end);
|
|
44
|
+
bySpan.set(`${span.start}:${span.end}`, {
|
|
45
|
+
...span,
|
|
46
|
+
surface,
|
|
47
|
+
normalized: normalizeResolverText(surface),
|
|
48
|
+
requested: true,
|
|
49
|
+
matches: []
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
for (const entity of entities) {
|
|
53
|
+
for (const alias of [entity.name, ...(entity.aliases ?? [])]) {
|
|
54
|
+
for (const variant of resolverVariants(alias)) {
|
|
55
|
+
for (const span of findAll(text, variant.value)) {
|
|
56
|
+
const key = `${span.start}:${span.end}`;
|
|
57
|
+
const bucket = bySpan.get(key) ?? { ...span, normalized: normalizeResolverText(span.surface), requested: false, matches: [] };
|
|
58
|
+
const evidence = [{ kind: alias === entity.name ? 'name_exact' : 'alias_exact' }];
|
|
59
|
+
if (variant.honorific)
|
|
60
|
+
evidence.push({ kind: 'honorific_variant' });
|
|
61
|
+
bucket.matches.push({ entity, evidence });
|
|
62
|
+
bySpan.set(key, bucket);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
for (const bucket of bySpan.values()) {
|
|
68
|
+
for (const entity of entities) {
|
|
69
|
+
for (const alias of [entity.name, ...(entity.aliases ?? [])]) {
|
|
70
|
+
const variant = resolverVariants(alias).find((item) => item.value === bucket.normalized);
|
|
71
|
+
if (!variant)
|
|
72
|
+
continue;
|
|
73
|
+
const evidence = [{ kind: alias === entity.name ? 'name_exact' : 'alias_exact' }];
|
|
74
|
+
if (variant.honorific)
|
|
75
|
+
evidence.push({ kind: 'honorific_variant' });
|
|
76
|
+
bucket.matches.push({ entity, evidence });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return [...bySpan.values()]
|
|
81
|
+
.sort((left, right) => left.start - right.start || right.end - left.end)
|
|
82
|
+
.filter((mention, index, all) => mention.requested || !all.some((other, otherIndex) => otherIndex !== index && other.start <= mention.start && other.end >= mention.end && (other.end - other.start) > (mention.end - mention.start)))
|
|
83
|
+
.map((mention) => resolveMention(mention, graph, asOf, scope));
|
|
84
|
+
}
|
|
85
|
+
function resolveMention(mention, graph, asOf, scope) {
|
|
86
|
+
const deduplicated = new Map();
|
|
87
|
+
for (const match of mention.matches) {
|
|
88
|
+
const scopedPaths = scope ? projectPaths(match.entity, graph, asOf, scope.projectIds) : [];
|
|
89
|
+
if (scope?.policy === 'strict' && scopedPaths.length === 0)
|
|
90
|
+
continue;
|
|
91
|
+
const evidence = [...match.evidence];
|
|
92
|
+
for (const path of scopedPaths.sort((left, right) => stableJson(left).localeCompare(stableJson(right)))) {
|
|
93
|
+
evidence.push(path.edgeIds.length === 0
|
|
94
|
+
? { kind: 'project_scope', projectId: path.projectId }
|
|
95
|
+
: path.edgeIds.length === 1
|
|
96
|
+
? { kind: 'project_scope', projectId: path.projectId, edgeId: path.edgeIds[0] }
|
|
97
|
+
: { kind: 'relation_path', edgeIds: path.edgeIds });
|
|
98
|
+
}
|
|
99
|
+
evidence.push({ kind: 'valid_at', asOf });
|
|
100
|
+
const stableEvidence = deduplicateAndSortEvidence(evidence);
|
|
101
|
+
const score = match.evidence[0]?.kind === 'name_exact' ? 100 : 95;
|
|
102
|
+
const scopedScore = scopedPaths.length > 0 ? score + 20 : score;
|
|
103
|
+
const existing = deduplicated.get(match.entity.id);
|
|
104
|
+
if (!existing || existing.score < scopedScore)
|
|
105
|
+
deduplicated.set(match.entity.id, { entityId: match.entity.id, score: scopedScore, evidence: stableEvidence });
|
|
106
|
+
}
|
|
107
|
+
let candidates = [...deduplicated.values()].sort((left, right) => right.score - left.score || left.entityId.localeCompare(right.entityId));
|
|
108
|
+
if (scope?.policy === 'allow_global_fallback' && candidates.some((candidate) => candidate.evidence.some(isScopeEvidence))) {
|
|
109
|
+
candidates = candidates.filter((candidate) => candidate.evidence.some(isScopeEvidence));
|
|
110
|
+
}
|
|
111
|
+
const topScore = candidates[0]?.score;
|
|
112
|
+
const top = candidates.filter((candidate) => candidate.score === topScore);
|
|
113
|
+
const status = candidates.length === 0 ? 'unresolved' : top.length === 1 ? 'resolved' : 'ambiguous';
|
|
114
|
+
return {
|
|
115
|
+
span: { start: mention.start, end: mention.end },
|
|
116
|
+
surface: mention.surface,
|
|
117
|
+
surfaceHash: sha256(mention.surface),
|
|
118
|
+
normalized: mention.normalized,
|
|
119
|
+
status,
|
|
120
|
+
...(status === 'resolved' ? { selectedEntityId: top[0].entityId } : {}),
|
|
121
|
+
candidates
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
function projectPaths(entity, graph, asOf, projectIds) {
|
|
125
|
+
const activeEntities = new Set(graph.entities
|
|
126
|
+
.filter((candidate) => isActiveAt(candidate, asOf))
|
|
127
|
+
.map((candidate) => candidate.id));
|
|
128
|
+
const activeProjectIds = new Set(graph.entities
|
|
129
|
+
.filter((candidate) => candidate.type === 'project' && activeEntities.has(candidate.id))
|
|
130
|
+
.map((candidate) => candidate.id));
|
|
131
|
+
const scopedProjectIds = projectIds.filter((projectId) => activeProjectIds.has(projectId));
|
|
132
|
+
if (entity.type === 'project') {
|
|
133
|
+
return scopedProjectIds.includes(entity.id) ? [{ projectId: entity.id, edgeIds: [] }] : [];
|
|
134
|
+
}
|
|
135
|
+
const activeEdges = graph.edges
|
|
136
|
+
.filter((edge) => isActiveAt(edge, asOf) && activeEntities.has(edge.fromId) && activeEntities.has(edge.toId))
|
|
137
|
+
.sort((left, right) => left.id < right.id ? -1 : left.id > right.id ? 1 : 0);
|
|
138
|
+
const queue = [{ entityId: entity.id, edgeIds: [] }];
|
|
139
|
+
const visited = new Set([entity.id]);
|
|
140
|
+
const paths = [];
|
|
141
|
+
while (queue.length > 0) {
|
|
142
|
+
const current = queue.shift();
|
|
143
|
+
for (const edge of activeEdges) {
|
|
144
|
+
const relation = getCanonicalRelation(edge.relation);
|
|
145
|
+
if (relation.scopeTraversal === 'none' || relation.traversalDirection === 'none')
|
|
146
|
+
continue;
|
|
147
|
+
const nextEntityId = relation.traversalDirection === 'forward'
|
|
148
|
+
? edge.fromId === current.entityId ? edge.toId : undefined
|
|
149
|
+
: edge.toId === current.entityId ? edge.fromId : undefined;
|
|
150
|
+
if (!nextEntityId)
|
|
151
|
+
continue;
|
|
152
|
+
const edgeIds = [...current.edgeIds, edge.id];
|
|
153
|
+
if (scopedProjectIds.includes(nextEntityId)) {
|
|
154
|
+
paths.push({ projectId: nextEntityId, edgeIds });
|
|
155
|
+
}
|
|
156
|
+
if (relation.scopeTraversal === 'project_transitive' && !visited.has(nextEntityId)) {
|
|
157
|
+
visited.add(nextEntityId);
|
|
158
|
+
queue.push({ entityId: nextEntityId, edgeIds });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return [...new Map(paths.map((path) => [stableJson(path), path])).entries()]
|
|
163
|
+
.sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0)
|
|
164
|
+
.map(([, path]) => path);
|
|
165
|
+
}
|
|
166
|
+
function isScopeEvidence(evidence) {
|
|
167
|
+
return evidence.kind === 'project_scope' || evidence.kind === 'relation_path';
|
|
168
|
+
}
|
|
169
|
+
function deduplicateAndSortEvidence(evidence) {
|
|
170
|
+
return [...new Map(evidence.map((item) => [stableJson(item), item])).entries()]
|
|
171
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
172
|
+
.map(([, item]) => item);
|
|
173
|
+
}
|
|
174
|
+
function finalize(input, asOf, projectScope, mentions, resolutionStatus, summary) {
|
|
175
|
+
const graph = input.source.graph;
|
|
176
|
+
const payload = {
|
|
177
|
+
schemaVersion: 1,
|
|
178
|
+
resolverVersion,
|
|
179
|
+
graphSchemaVersion: graph?.version ?? null,
|
|
180
|
+
ontology: graph?.ontology ?? null,
|
|
181
|
+
request: {
|
|
182
|
+
textSha256: sha256(input.text),
|
|
183
|
+
textLength: input.text.length,
|
|
184
|
+
...(projectScope ? { projectScope } : {}),
|
|
185
|
+
asOf,
|
|
186
|
+
...(input.entityTypes ? { entityTypes: [...new Set(input.entityTypes)].sort() } : {})
|
|
187
|
+
},
|
|
188
|
+
source: {
|
|
189
|
+
authority: input.source.authority,
|
|
190
|
+
status: input.source.status,
|
|
191
|
+
...(input.source.revision ? { revisionSha256: sha256(input.source.revision) } : {}),
|
|
192
|
+
issueCodes: [...new Set((input.source.issues ?? []).map((issue) => issue.code))].sort()
|
|
193
|
+
},
|
|
194
|
+
resolutionStatus,
|
|
195
|
+
mentions: mentions.map(({ surface: _surface, normalized: _normalized, ...mention }) => mention),
|
|
196
|
+
summary
|
|
197
|
+
};
|
|
198
|
+
return { mentions, receipt: { ...payload, digest: sha256(stableJson(payload)) } };
|
|
199
|
+
}
|
|
200
|
+
export function verifyResolutionReceipt(receipt, expected) {
|
|
201
|
+
try {
|
|
202
|
+
const { digest, ...payload } = receipt;
|
|
203
|
+
if (receipt.schemaVersion !== 1 || receipt.resolverVersion !== resolverVersion)
|
|
204
|
+
return false;
|
|
205
|
+
if (sha256(stableJson(payload)) !== digest)
|
|
206
|
+
return false;
|
|
207
|
+
if (receipt.request.textSha256 !== sha256(expected.text) || receipt.request.textLength !== expected.text.length)
|
|
208
|
+
return false;
|
|
209
|
+
if (receipt.request.asOf !== expected.asOf)
|
|
210
|
+
return false;
|
|
211
|
+
if (receipt.source.status !== expected.sourceStatus)
|
|
212
|
+
return false;
|
|
213
|
+
if (receipt.source.revisionSha256 !== (expected.sourceRevision ? sha256(expected.sourceRevision) : undefined))
|
|
214
|
+
return false;
|
|
215
|
+
const sourceBlocked = receipt.source.status === 'unavailable' || receipt.source.status === 'invalid';
|
|
216
|
+
if (sourceBlocked !== (receipt.resolutionStatus === 'blocked'))
|
|
217
|
+
return false;
|
|
218
|
+
if (sourceBlocked) {
|
|
219
|
+
if (receipt.summary !== null || receipt.graphSchemaVersion !== null || receipt.ontology !== null || receipt.mentions.length !== 0)
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
else if (receipt.graphSchemaVersion !== 2 || !receipt.ontology || receipt.summary === null) {
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
for (const mention of receipt.mentions) {
|
|
226
|
+
const { start, end } = mention.span;
|
|
227
|
+
if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end <= start || end > expected.text.length)
|
|
228
|
+
return false;
|
|
229
|
+
if (mention.surfaceHash !== sha256(expected.text.slice(start, end)))
|
|
230
|
+
return false;
|
|
231
|
+
const selected = mention.selectedEntityId;
|
|
232
|
+
const candidateIds = new Set(mention.candidates.map((candidate) => candidate.entityId));
|
|
233
|
+
if ((mention.status === 'resolved') !== Boolean(selected))
|
|
234
|
+
return false;
|
|
235
|
+
if (selected && !candidateIds.has(selected))
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
238
|
+
if (receipt.summary) {
|
|
239
|
+
const computed = {
|
|
240
|
+
resolved: receipt.mentions.filter((mention) => mention.status === 'resolved').length,
|
|
241
|
+
ambiguous: receipt.mentions.filter((mention) => mention.status === 'ambiguous').length,
|
|
242
|
+
unresolved: receipt.mentions.filter((mention) => mention.status === 'unresolved').length
|
|
243
|
+
};
|
|
244
|
+
if (stableJson(computed) !== stableJson(receipt.summary))
|
|
245
|
+
return false;
|
|
246
|
+
const expectedStatus = receipt.mentions.length === 0 || computed.resolved === 0
|
|
247
|
+
? 'none'
|
|
248
|
+
: computed.ambiguous + computed.unresolved === 0 && receipt.source.status === 'complete'
|
|
249
|
+
? 'complete'
|
|
250
|
+
: 'partial';
|
|
251
|
+
if (receipt.resolutionStatus !== expectedStatus)
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
const recomputed = resolveText({
|
|
255
|
+
text: expected.text,
|
|
256
|
+
...(expected.mentionSpans ? { mentionSpans: expected.mentionSpans } : {}),
|
|
257
|
+
...(expected.projectScope ? { projectScope: expected.projectScope } : {}),
|
|
258
|
+
asOf: expected.asOf,
|
|
259
|
+
...(expected.entityTypes ? { entityTypes: expected.entityTypes } : {}),
|
|
260
|
+
source: {
|
|
261
|
+
authority: 'local_graph',
|
|
262
|
+
status: expected.sourceStatus,
|
|
263
|
+
...(expected.sourceRevision ? { revision: expected.sourceRevision } : {}),
|
|
264
|
+
issues: (expected.sourceIssueCodes ?? []).map((code) => ({ code, message: '' })),
|
|
265
|
+
...(expected.graph ? { graph: expected.graph } : {})
|
|
266
|
+
}
|
|
267
|
+
}).receipt;
|
|
268
|
+
return recomputed.digest === receipt.digest;
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
function assertResolveTextInput(input) {
|
|
275
|
+
if (!input || typeof input !== 'object' || typeof input.text !== 'string') {
|
|
276
|
+
throw new Error('RESOLUTION-INPUT-TEXT: text must be a string');
|
|
277
|
+
}
|
|
278
|
+
if (!input.source || input.source.authority !== 'local_graph' || !['complete', 'partial', 'unavailable', 'invalid'].includes(input.source.status)) {
|
|
279
|
+
throw new Error('RESOLUTION-INPUT-SOURCE: source authority and status are invalid');
|
|
280
|
+
}
|
|
281
|
+
if ((input.source.status === 'complete' || input.source.status === 'partial') && !input.source.graph) {
|
|
282
|
+
throw new Error(`RESOLUTION-SOURCE-GRAPH-REQUIRED: source status ${input.source.status} requires a Graph snapshot`);
|
|
283
|
+
}
|
|
284
|
+
if ((input.source.status === 'unavailable' || input.source.status === 'invalid') && input.source.graph) {
|
|
285
|
+
throw new Error(`RESOLUTION-SOURCE-GRAPH-FORBIDDEN: source status ${input.source.status} must not carry a trusted Graph snapshot`);
|
|
286
|
+
}
|
|
287
|
+
if (input.source.revision !== undefined && typeof input.source.revision !== 'string') {
|
|
288
|
+
throw new Error('RESOLUTION-INPUT-REVISION: source revision must be a string');
|
|
289
|
+
}
|
|
290
|
+
if (input.source.issues !== undefined && (!Array.isArray(input.source.issues) || input.source.issues.some((issue) => !issue || typeof issue.code !== 'string' || typeof issue.message !== 'string'))) {
|
|
291
|
+
throw new Error('RESOLUTION-INPUT-ISSUES: source issues must contain string code and message');
|
|
292
|
+
}
|
|
293
|
+
if (input.projectScope) {
|
|
294
|
+
if (!Array.isArray(input.projectScope.projectIds) || input.projectScope.projectIds.some((id) => typeof id !== 'string' || id.trim() === '')) {
|
|
295
|
+
throw new Error('RESOLUTION-INPUT-PROJECT-SCOPE: project IDs must be non-empty strings');
|
|
296
|
+
}
|
|
297
|
+
if (input.projectScope.policy !== undefined && !['strict', 'prefer_project', 'allow_global_fallback'].includes(input.projectScope.policy)) {
|
|
298
|
+
throw new Error('RESOLUTION-INPUT-SCOPE-POLICY: unsupported project scope policy');
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
if (input.entityTypes !== undefined && (!Array.isArray(input.entityTypes) || input.entityTypes.some((type) => !['person', 'org', 'project', 'decision'].includes(type)))) {
|
|
302
|
+
throw new Error('RESOLUTION-INPUT-ENTITY-TYPES: unsupported canonical entity type');
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
export function normalizeResolverText(value) {
|
|
306
|
+
return value.normalize('NFKC').toLocaleLowerCase('ja-JP').replace(/\s+/gu, ' ').trim();
|
|
307
|
+
}
|
|
308
|
+
function resolverVariants(value) {
|
|
309
|
+
const normalized = normalizeResolverText(value);
|
|
310
|
+
const values = new Map([[normalized, false]]);
|
|
311
|
+
for (const suffix of ['さん', '様', '氏'])
|
|
312
|
+
values.set(`${normalized}${suffix}`, true);
|
|
313
|
+
return [...values].map(([variant, honorific]) => ({ value: variant, honorific }));
|
|
314
|
+
}
|
|
315
|
+
function findAll(text, search) {
|
|
316
|
+
const { value: normalizedText, ranges } = normalizeWithRanges(text);
|
|
317
|
+
const result = [];
|
|
318
|
+
let from = 0;
|
|
319
|
+
while (from <= normalizedText.length) {
|
|
320
|
+
const start = normalizedText.indexOf(search, from);
|
|
321
|
+
if (start < 0)
|
|
322
|
+
break;
|
|
323
|
+
const end = start + search.length;
|
|
324
|
+
const sourceStart = ranges[start]?.start;
|
|
325
|
+
const sourceEnd = ranges[end - 1]?.end;
|
|
326
|
+
if (sourceStart !== undefined && sourceEnd !== undefined) {
|
|
327
|
+
result.push({ start: sourceStart, end: sourceEnd, surface: text.slice(sourceStart, sourceEnd) });
|
|
328
|
+
}
|
|
329
|
+
from = start + Math.max(search.length, 1);
|
|
330
|
+
}
|
|
331
|
+
return result;
|
|
332
|
+
}
|
|
333
|
+
function normalizeWithRanges(text) {
|
|
334
|
+
const segments = [...new Intl.Segmenter('ja', { granularity: 'grapheme' }).segment(text)];
|
|
335
|
+
const characters = [];
|
|
336
|
+
const ranges = [];
|
|
337
|
+
let pendingWhitespaceStart;
|
|
338
|
+
let pendingWhitespaceEnd;
|
|
339
|
+
for (const segment of segments) {
|
|
340
|
+
const normalized = segment.segment.normalize('NFKC').toLocaleLowerCase('ja-JP');
|
|
341
|
+
const end = segment.index + segment.segment.length;
|
|
342
|
+
if (/^\s+$/u.test(normalized)) {
|
|
343
|
+
if (characters.length > 0) {
|
|
344
|
+
pendingWhitespaceStart ??= segment.index;
|
|
345
|
+
pendingWhitespaceEnd = end;
|
|
346
|
+
}
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
if (pendingWhitespaceStart !== undefined) {
|
|
350
|
+
characters.push(' ');
|
|
351
|
+
ranges.push({ start: pendingWhitespaceStart, end: pendingWhitespaceEnd });
|
|
352
|
+
pendingWhitespaceStart = undefined;
|
|
353
|
+
pendingWhitespaceEnd = undefined;
|
|
354
|
+
}
|
|
355
|
+
for (const character of normalized) {
|
|
356
|
+
characters.push(character);
|
|
357
|
+
for (let index = 0; index < character.length; index += 1) {
|
|
358
|
+
if (index > 0)
|
|
359
|
+
characters.push('');
|
|
360
|
+
ranges.push({ start: segment.index, end });
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
return { value: characters.join(''), ranges };
|
|
365
|
+
}
|
|
366
|
+
function stableJson(value) {
|
|
367
|
+
return JSON.stringify(canonicalize(value));
|
|
368
|
+
}
|
|
369
|
+
function canonicalize(value) {
|
|
370
|
+
if (Array.isArray(value))
|
|
371
|
+
return value.map(canonicalize);
|
|
372
|
+
if (value && typeof value === 'object') {
|
|
373
|
+
return Object.fromEntries(Object.entries(value)
|
|
374
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
375
|
+
.map(([key, item]) => [key, canonicalize(item)]));
|
|
376
|
+
}
|
|
377
|
+
return value;
|
|
378
|
+
}
|
|
379
|
+
function sha256(value) {
|
|
380
|
+
return createHash('sha256').update(value).digest('hex');
|
|
381
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export interface GraphDiagnosis {
|
|
2
|
+
status: 'healthy' | 'issues' | 'migration_required' | 'invalid' | 'unavailable';
|
|
3
|
+
schemaVersion: number | null;
|
|
4
|
+
ontology: unknown;
|
|
5
|
+
asOf: string;
|
|
6
|
+
migrationRequired: boolean;
|
|
7
|
+
counts: {
|
|
8
|
+
entities: number;
|
|
9
|
+
edges: number;
|
|
10
|
+
activeEdges: number;
|
|
11
|
+
danglingEdges: number;
|
|
12
|
+
invalidEdges: number;
|
|
13
|
+
duplicateEdges: number;
|
|
14
|
+
duplicateEntities: number;
|
|
15
|
+
unresolvedRecords: number;
|
|
16
|
+
projections: number;
|
|
17
|
+
};
|
|
18
|
+
issues: Array<{
|
|
19
|
+
class: 'dangling' | 'invalid' | 'duplicate' | 'unresolved' | 'projection' | 'migration' | 'unavailable';
|
|
20
|
+
recordId?: string;
|
|
21
|
+
detail: string;
|
|
22
|
+
}>;
|
|
23
|
+
}
|
|
24
|
+
export declare function diagnoseGraph(dataDir: string, asOf?: string): Promise<GraphDiagnosis>;
|