sentinelayer-cli 0.46.4 → 0.48.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.
Files changed (137) hide show
  1. package/README.md +63 -3
  2. package/package.json +12 -7
  3. package/skills/omar-gate-setup/SKILL.md +103 -0
  4. package/skills/senti-agent-identity/SKILL.md +80 -0
  5. package/skills/senti-judge/SKILL.md +139 -0
  6. package/skills/senti-room-notifications/SKILL.md +73 -0
  7. package/skills/senti-room-notifications/listen-wrapper.cjs +150 -0
  8. package/skills/senti-room-notifications/package.json +1 -0
  9. package/skills/senti-room-notifications/test-classifier.js +109 -0
  10. package/skills/senti-room-notifications/test-receipt.js +54 -0
  11. package/skills/sentinelayer-cli/SKILL.md +222 -0
  12. package/src/agents/devtestbot/runner.js +22 -11
  13. package/src/agents/devtestbot/tool.js +24 -11
  14. package/src/agents/jules/fix-cycle.js +7 -1
  15. package/src/agents/jules/tools/runtime-audit.js +17 -12
  16. package/src/agents/shared-tools/dispatch-core.js +10 -5
  17. package/src/ai/aidenid.js +4 -2
  18. package/src/ai/domain-target-store.js +11 -5
  19. package/src/ai/identity-store.js +11 -5
  20. package/src/ai/site-store.js +8 -4
  21. package/src/audit/orchestrator.js +21 -14
  22. package/src/audit/package.js +6 -4
  23. package/src/audit/persona-loop.js +7 -0
  24. package/src/audit/replay.js +4 -2
  25. package/src/auth/gate.js +46 -8
  26. package/src/auth/service.js +34 -15
  27. package/src/auth/session-store.js +105 -47
  28. package/src/board/evidence-resolvers.js +3 -4
  29. package/src/cli.js +23 -3
  30. package/src/commands/ai/identity-lifecycle.js +30 -26
  31. package/src/commands/ai/provision-governance.js +32 -24
  32. package/src/commands/ai/shared.js +6 -4
  33. package/src/commands/audit.js +14 -12
  34. package/src/commands/auth.js +24 -4
  35. package/src/commands/board.js +2 -2
  36. package/src/commands/chat.js +8 -6
  37. package/src/commands/judge.js +125 -0
  38. package/src/commands/legacy-args.js +1 -0
  39. package/src/commands/mcp.js +45 -52
  40. package/src/commands/omargate.js +2 -1
  41. package/src/commands/plugin.js +11 -3
  42. package/src/commands/review.js +9 -4
  43. package/src/commands/scan.js +7 -7
  44. package/src/commands/session.js +607 -57
  45. package/src/commands/skills.js +45 -0
  46. package/src/commands/watch.js +9 -7
  47. package/src/config/output-filesystem.js +156 -0
  48. package/src/config/schema.js +4 -2
  49. package/src/config/service.js +40 -4
  50. package/src/cost/history.js +15 -9
  51. package/src/daemon/artifact-lineage.js +15 -12
  52. package/src/daemon/assignment-ledger.js +31 -25
  53. package/src/daemon/ast-drift.js +7 -3
  54. package/src/daemon/budget-governor.js +26 -20
  55. package/src/daemon/error-worker.js +28 -22
  56. package/src/daemon/fix-cycle.js +5 -4
  57. package/src/daemon/hybrid-mapper.js +19 -16
  58. package/src/daemon/ingest-refresh.js +4 -0
  59. package/src/daemon/jira-lifecycle.js +21 -17
  60. package/src/daemon/operator-control.js +18 -15
  61. package/src/daemon/reliability-lane.js +18 -15
  62. package/src/daemon/scope-engine.js +12 -8
  63. package/src/daemon/watchdog.js +15 -12
  64. package/src/engram/build-state.js +24 -3
  65. package/src/engram/capability-index.js +367 -0
  66. package/src/engram/compile-graph.js +1063 -0
  67. package/src/engram/contracts/action-receipt.js +82 -0
  68. package/src/engram/contracts/capability-index.js +259 -0
  69. package/src/engram/contracts/capability.js +377 -0
  70. package/src/engram/contracts/content-hash.js +8 -0
  71. package/src/engram/contracts/evidence.js +258 -0
  72. package/src/engram/contracts/graph.js +594 -0
  73. package/src/engram/contracts/identity.js +8 -0
  74. package/src/engram/contracts/index.js +149 -0
  75. package/src/engram/contracts/media-asset.js +85 -0
  76. package/src/engram/contracts/money.js +249 -0
  77. package/src/engram/contracts/source-snapshot.js +57 -0
  78. package/src/engram/contracts/task-context-pack.js +114 -0
  79. package/src/engram/contracts/temporal-segment.js +72 -0
  80. package/src/engram/contracts/versions.js +96 -0
  81. package/src/engram/extract-html.js +692 -0
  82. package/src/engram/graph-store.js +877 -0
  83. package/src/engram/index.js +98 -14
  84. package/src/engram/ingest-site.js +132 -0
  85. package/src/engram/live-state.js +1180 -0
  86. package/src/engram/namespace.js +34 -1
  87. package/src/engram/retrieve-site.js +753 -0
  88. package/src/engram/safe-reader.js +597 -0
  89. package/src/engram/source-intake.js +251 -0
  90. package/src/engram/store.js +227 -9
  91. package/src/ingest/engine.js +21 -28
  92. package/src/judge/verdict.js +305 -0
  93. package/src/legacy-cli.js +96 -19
  94. package/src/mcp/bridge-inputs.js +28 -4
  95. package/src/mcp/cli-command-tools.js +70 -0
  96. package/src/mcp/registry.js +17 -13
  97. package/src/mcp/session-stdio-server.js +297 -19
  98. package/src/memory/blackboard.js +5 -4
  99. package/src/net/gh-api-path.js +35 -9
  100. package/src/net/test-egress-guard.js +16 -4
  101. package/src/plugin/manifest.js +12 -8
  102. package/src/review/ai-review.js +110 -10
  103. package/src/review/investor-dd-devtestbot.js +17 -6
  104. package/src/review/investor-dd-orchestrator.js +21 -13
  105. package/src/review/local-review.js +26 -16
  106. package/src/review/omargate-cache.js +5 -5
  107. package/src/review/omargate-orchestrator.js +89 -17
  108. package/src/review/replay.js +9 -4
  109. package/src/review/report.js +22 -13
  110. package/src/review/typesafe-severity.js +201 -0
  111. package/src/session/admission-auth.js +34 -7
  112. package/src/session/admission.js +322 -71
  113. package/src/session/agent-access.js +137 -0
  114. package/src/session/agent-registry.js +2 -1
  115. package/src/session/attachment-summary.js +77 -0
  116. package/src/session/edit-summary.js +19 -0
  117. package/src/session/invitations.js +65 -1
  118. package/src/session/mentions.js +17 -9
  119. package/src/session/message-edits.js +5 -4
  120. package/src/session/preview.js +10 -2
  121. package/src/session/recap.js +3 -2
  122. package/src/session/room-files.js +519 -0
  123. package/src/session/route-classes.js +22 -8
  124. package/src/session/setup-guides.js +4 -0
  125. package/src/session/standup.js +192 -0
  126. package/src/session/sync.js +229 -10
  127. package/src/session/tickets.js +41 -0
  128. package/src/session/transcript.js +2 -1
  129. package/src/session/wake/resolve-target.js +7 -3
  130. package/src/skills/listener-runtime.cjs +92 -0
  131. package/src/skills/service.js +207 -0
  132. package/src/swarm/factory.js +5 -5
  133. package/src/swarm/pentest.js +9 -9
  134. package/src/swarm/report.js +4 -4
  135. package/src/swarm/runtime.js +15 -12
  136. package/src/swarm/scenario-dsl.js +7 -5
  137. package/src/telemetry/ledger.js +16 -7
@@ -0,0 +1,1063 @@
1
+ /**
2
+ * ENGRAM §EW05 — Compile source snapshots into the canonical capability graph.
3
+ *
4
+ * Reads a site's snapshots through the EW03 graph store and publishes ONE validated
5
+ * generation atomically. Implements mandatory constraints:
6
+ *
7
+ * • Stable tenant/source IDs; pages link to entities rather than replacing all
8
+ * business entities with URLs.
9
+ * • Resolve identifiers deterministically first; ambiguous entity links remain
10
+ * candidates.
11
+ * • Inferred claims cannot overwrite authoritative facts. Preserve validity time,
12
+ * locale and source precedence.
13
+ * • No action generated from a guessed button coordinate, guessed SKU or arbitrary
14
+ * executable text.
15
+ *
16
+ * Adds what a top professional would add:
17
+ * • Candidate/published generations with contradiction records.
18
+ * • Deterministic rebuild: same snapshots → same graph identities.
19
+ * • Graph cycles/bounds detection.
20
+ *
21
+ * Library only. Node builtins + existing EW02/EW03 contracts. No new dependency.
22
+ */
23
+
24
+ import { createHash } from 'node:crypto';
25
+
26
+ import { graphSchema, isMoneyFieldName, isPluralMoneyFieldName, quantitySchema } from './contracts/graph.js';
27
+ import { moneySchema, currencyExponent, KNOWN_CURRENCIES, toMinorUnits } from './contracts/money.js';
28
+ import { GRAPH_SCHEMA_VERSION } from './contracts/versions.js';
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Canonical predicate vocabulary (from the EW05 proposed solution)
32
+ // ---------------------------------------------------------------------------
33
+
34
+ export const EW05_PREDICATES = Object.freeze({
35
+ CONTAINS: 'contains',
36
+ DESCRIBES: 'describes',
37
+ VARIANT_OF: 'variant_of',
38
+ HAS_MEDIA: 'has_media',
39
+ SUPPORTED_BY: 'supported_by',
40
+ REFERENCES: 'references',
41
+ SAME_AS_CANDIDATE: 'same_as_candidate',
42
+ });
43
+
44
+ export const EW05_PREDICATE_SET = new Set(Object.values(EW05_PREDICATES));
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Entity kinds (deterministic, stable identifiers)
48
+ // ---------------------------------------------------------------------------
49
+
50
+ export const ENTITY_KIND = Object.freeze({
51
+ PAGE: 'page',
52
+ PRODUCT: 'product',
53
+ OFFERING: 'offering',
54
+ DOCUMENT: 'document',
55
+ CAPABILITY: 'capability',
56
+ MEDIA: 'media',
57
+ ORGANISATION: 'organisation',
58
+ UNKNOWN: 'unknown',
59
+ });
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Generation status
63
+ // ---------------------------------------------------------------------------
64
+
65
+ export const GENERATION_STATUS = Object.freeze({
66
+ CANDIDATE: 'candidate',
67
+ PUBLISHED: 'published',
68
+ SUPERSEDED: 'superseded',
69
+ });
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // Identity helpers — deterministic, source-normalised
73
+ // ---------------------------------------------------------------------------
74
+
75
+ const ID_SEPARATOR = ':';
76
+
77
+ /**
78
+ * Deterministic entity id from tenant scope + kind + normalised key.
79
+ */
80
+ function entityId(tenantScope, kind, key) {
81
+ // Identifier-first: identity is derived from the normalised key first.
82
+ // The kind is included in the final id only for human-readability but
83
+ // the digest is key-only so the same name resolves to the same identity
84
+ // regardless of how different sources classify the @type.
85
+ const normalised = String(key ?? '').toLowerCase().replace(/\s+/g, ' ').trim();
86
+ if (!normalised) return null;
87
+ const digest = createHash('sha256').update(`${tenantScope}|${normalised}`, 'utf-8').digest('hex').slice(0, 16);
88
+ return `${tenantScope}${ID_SEPARATOR}${kind}${ID_SEPARATOR}${digest}`;
89
+ }
90
+
91
+ /**
92
+ * Deterministic assertion id scoped to a source snapshot.
93
+ */
94
+ function assertionId(sourceId, index) {
95
+ return `${sourceId}${ID_SEPARATOR}assertion${ID_SEPARATOR}${index}`;
96
+ }
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // Normalisation: canonical URL → page identity
100
+ // ---------------------------------------------------------------------------
101
+
102
+ /**
103
+ * Normalise a URL for deterministic identity.
104
+ */
105
+ function normaliseUrl(raw) {
106
+ if (typeof raw !== 'string' || !raw) return null;
107
+ let u;
108
+ try {
109
+ u = new URL(raw);
110
+ } catch {
111
+ return null;
112
+ }
113
+ if (u.protocol !== 'https:' && u.protocol !== 'http:') return null;
114
+ u.hash = '';
115
+ const trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',
116
+ 'fbclid', 'gclid', 'msclkid', 'ref', 'source'];
117
+ for (const p of trackingParams) {
118
+ u.searchParams.delete(p);
119
+ }
120
+ u.searchParams.sort();
121
+ u.pathname = u.pathname.replace(/\/+$/, '');
122
+ return u.toString();
123
+ }
124
+
125
+ /**
126
+ * Deterministic page entity id from a normalised URL.
127
+ */
128
+ function pageEntityId(tenantScope, url) {
129
+ const normalised = normaliseUrl(url);
130
+ if (!normalised) return null;
131
+ return entityId(tenantScope, ENTITY_KIND.PAGE, normalised);
132
+ }
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // Fact extraction from snapshots — never guesses, always cites source
136
+ // ---------------------------------------------------------------------------
137
+
138
+ function extractEntities(tenantScope, sourceId, sourceVersion, capturedAt, facts) {
139
+ const unresolvedFacts = [];
140
+ const entities = [];
141
+ if (!facts) return entities;
142
+
143
+ for (const item of facts.jsonLd || []) {
144
+ if (!item || typeof item !== 'object') continue;
145
+ const type = Array.isArray(item['@type'])
146
+ ? item['@type'][0]
147
+ : item['@type'];
148
+ if (!type || typeof type !== 'string') continue;
149
+
150
+ let kind = ENTITY_KIND.UNKNOWN;
151
+ const typeLower = type.toLowerCase();
152
+ if (typeLower === 'product') kind = ENTITY_KIND.PRODUCT;
153
+ else if (typeLower === 'offering' || typeLower === 'offer') kind = ENTITY_KIND.OFFERING;
154
+ else if (typeLower === 'organization' || typeLower === 'organisation') kind = ENTITY_KIND.ORGANISATION;
155
+ else if (typeLower === 'mediaobject' || typeLower === 'imageobject' || typeLower === 'videoobject') kind = ENTITY_KIND.MEDIA;
156
+ else if (typeLower === 'webpage' || typeLower === 'website') continue;
157
+
158
+ // Identifier-first resolution order
159
+ let identityKey = null;
160
+ let keyType = 'name';
161
+ if (item['@id']) { identityKey = item['@id']; keyType = '@id'; }
162
+ else if (item.sku) { identityKey = item.sku; keyType = 'sku'; }
163
+ else if (item.gtin) { identityKey = item.gtin; keyType = 'gtin'; }
164
+ else if (item.productID) { identityKey = item.productID; keyType = 'productID'; }
165
+ else if (item.identifier) { identityKey = item.identifier; keyType = 'identifier'; }
166
+ else if (typeof item.name === 'string' || (typeof item.name === 'number' && Number.isFinite(item.name))) { identityKey = String(item.name); keyType = 'name'; }
167
+
168
+ if (!identityKey) continue;
169
+
170
+ // Name-only entities get source-scoped ids
171
+ const idKey = keyType === 'name'
172
+ ? `${sourceId}|${identityKey}`
173
+ : identityKey;
174
+ const id = entityId(tenantScope, kind, idKey);
175
+ if (!id) continue;
176
+
177
+ const attributes = {};
178
+ for (const [k, v] of Object.entries(item)) {
179
+ if (k === '@context' || k === '@type') continue;
180
+ if (v !== undefined && v !== null) attributes[k] = v;
181
+ }
182
+
183
+ const normalized = Object.keys(attributes).length > 0
184
+ ? normalizeEntityAttributes(attributes)
185
+ : { attrs: undefined, unresolved: [] };
186
+ if (normalized.unresolved.length > 0) {
187
+ for (const uf of normalized.unresolved) {
188
+ uf.entityLabel = (typeof item.name === 'string' || (typeof item.name === 'number' && Number.isFinite(item.name))) ? String(item.name) : undefined;
189
+ uf.entityKind = kind;
190
+ }
191
+ unresolvedFacts.push(...normalized.unresolved);
192
+ }
193
+ entities.push({
194
+ id,
195
+ type: kind,
196
+ label: (typeof item.name === 'string' || (typeof item.name === 'number' && Number.isFinite(item.name))) ? String(item.name) : undefined,
197
+ attributes: normalized.attrs,
198
+ provenance: 'observed',
199
+ tenantScope,
200
+ sourceId,
201
+ sourceVersion,
202
+ capturedAt,
203
+ });
204
+ }
205
+
206
+ return { entities, unresolvedFacts };
207
+ }
208
+
209
+ // ---------------------------------------------------------------------------
210
+ // Money normalization — schema.org prices → canonical money objects
211
+ // ---------------------------------------------------------------------------
212
+
213
+ /**
214
+ * Container keys whose children may carry money values.
215
+ * e.g. offers.price, shippingDetails.shippingCost
216
+ */
217
+ const MONEY_CONTAINER_KEYS = new Set([
218
+ 'offers', 'Offers',
219
+ 'shippingDetails', 'ShippingDetails',
220
+ 'priceSpecification', 'PriceSpecification',
221
+ 'aggregateOffer', 'AggregateOffer',
222
+ 'hasVariant', 'HasVariant',
223
+ ]);
224
+
225
+ /** Keys that declare the currency of the amounts in the same object. */
226
+ const CURRENCY_KEYS = ['priceCurrency', 'currency', 'PriceCurrency', 'Currency'];
227
+
228
+ /** The amounts of a schema.org MonetaryAmount. */
229
+ const MONETARY_AMOUNT_KEYS = new Set(['value', 'minValue', 'maxValue']);
230
+
231
+ /** Amount ranges, from the low end to the high end. */
232
+ const AMOUNT_RANGES = [
233
+ ['lowPrice', 'highPrice'], ['minPrice', 'maxPrice'],
234
+ ['LowPrice', 'HighPrice'], ['MinPrice', 'MaxPrice'],
235
+ ['minValue', 'maxValue'],
236
+ ];
237
+
238
+ /** Identifier fields: numeric values here are stringified, not dropped. */
239
+ const IDENTIFIER_KEYS = new Set([
240
+ 'sku', 'gtin', 'gtin8', 'gtin12', 'gtin13', 'gtin14',
241
+ 'mpn', 'isbn', 'productID', 'productGroupID', 'identifier', 'id',
242
+ ]);
243
+
244
+ /** schema.org counts. A whole, non-negative value is an explicit count quantity. */
245
+ const COUNT_KEYS = new Set([
246
+ 'offerCount', 'reviewCount', 'ratingCount',
247
+ 'OfferCount', 'ReviewCount', 'RatingCount',
248
+ ]);
249
+
250
+ const DECIMAL_AMOUNT = /^-?(0|[1-9][0-9]*)(\.[0-9]+)?$/;
251
+
252
+ const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
253
+ const isMoney = (value) => moneySchema.safeParse(value).success;
254
+
255
+ /** Canonical money or an explicit quantity: a value already typed for the graph contract. */
256
+ function isTyped(value) {
257
+ return isMoney(value) || quantitySchema.safeParse(value).success;
258
+ }
259
+
260
+ /**
261
+ * True when the graph contract accepts `value` under the money name `key`:
262
+ * canonical money, or a list of it under a plural name such as prices.
263
+ */
264
+ function isMoneyFieldValue(key, value) {
265
+ return isMoney(value) ||
266
+ (Array.isArray(value) && isPluralMoneyFieldName(key) && value.every(isMoney));
267
+ }
268
+
269
+ /**
270
+ * A schema.org MonetaryAmount: typed so, or untyped with a currency and an
271
+ * amount, e.g. shippingRate: { value: "3.49", currency: "USD" }.
272
+ */
273
+ function isMonetaryAmount(value) {
274
+ if (!isPlainObject(value)) return false;
275
+ const types = [].concat(value['@type'] ?? []);
276
+ if (types.length > 0) return types.includes('MonetaryAmount');
277
+ return Object.hasOwn(value, 'currency') &&
278
+ [...MONETARY_AMOUNT_KEYS].some((key) => Object.hasOwn(value, key));
279
+ }
280
+
281
+ /** A MonetaryAmount that is one amount and its currency: canonical money once typed. */
282
+ function isSingleMonetaryAmount(value) {
283
+ return isMonetaryAmount(value) && Object.hasOwn(value, 'value') &&
284
+ (typeof value.value === 'string' || typeof value.value === 'number') &&
285
+ Object.keys(value).every((key) => key === '@type' || key === 'value' || key === 'currency');
286
+ }
287
+
288
+ /**
289
+ * True when `obj[key]` is an amount: a value under a name the graph contract
290
+ * treats as money, a MonetaryAmount { value, currency }, or a string or
291
+ * number amount of a larger MonetaryAmount.
292
+ */
293
+ function isAmount(obj, key, value) {
294
+ return isMoneyFieldName(key, value) || isSingleMonetaryAmount(value) ||
295
+ (MONETARY_AMOUNT_KEYS.has(key) && isMonetaryAmount(obj) &&
296
+ (typeof value === 'string' || typeof value === 'number'));
297
+ }
298
+
299
+ /**
300
+ * True when a value holds a number outside canonical money or an explicit
301
+ * quantity, i.e. a number the graph contract rejects as untyped.
302
+ */
303
+ function holdsBareNumber(value) {
304
+ if (typeof value === 'number') return true;
305
+ if (value === null || typeof value !== 'object') return false;
306
+ if (isTyped(value)) return false;
307
+ return Object.values(value).some(holdsBareNumber);
308
+ }
309
+
310
+ /** True when a value holds something under a money name that the graph contract rejects. */
311
+ function holdsUntypedMoney(value) {
312
+ if (value === null || typeof value !== 'object' || isTyped(value)) return false;
313
+ if (Array.isArray(value)) return value.some(holdsUntypedMoney);
314
+ return Object.entries(value).some(([key, item]) =>
315
+ (isMoneyFieldName(key, item) && !isMoneyFieldValue(key, item)) || holdsUntypedMoney(item));
316
+ }
317
+
318
+ /** True when a value holds NaN or an infinite number, which JSON cannot represent. */
319
+ function holdsNonFiniteNumber(value) {
320
+ if (typeof value === 'number') return !Number.isFinite(value);
321
+ return value !== null && typeof value === 'object' && Object.values(value).some(holdsNonFiniteNumber);
322
+ }
323
+
324
+ /**
325
+ * The currency of the amounts in `obj`: its own declared ISO 4217 code, else
326
+ * the enclosing object's. A declared code the money registry does not know
327
+ * ("points", "HKD", "eur"), or two codes that disagree, leave the amounts
328
+ * without a currency; an outer currency never takes their place.
329
+ * An exchange rate takes no currency from its specification or from any
330
+ * enclosing object.
331
+ */
332
+ function currencyInScope(obj, outer) {
333
+ const declared = CURRENCY_KEYS.filter((key) => Object.hasOwn(obj, key) && obj[key] != null)
334
+ .map((key) => obj[key]);
335
+ if (Object.hasOwn(obj, 'currentExchangeRate')) return null;
336
+ if (declared.length === 0) return outer;
337
+ const [code] = declared;
338
+ return declared.every((other) => other === code) && KNOWN_CURRENCIES.has(code) ? code : null;
339
+ }
340
+
341
+ /**
342
+ * Type the amount `raw` under `key` as canonical money in `currency`.
343
+ * Returns { value } or the reason it cannot be typed: amount_invalid when
344
+ * `raw` is not a decimal amount (text, a date, a flag, an object, a
345
+ * non-finite number) or is a negative amount, else currency_missing.
346
+ */
347
+ function typeAmount(key, raw, currency) {
348
+ const amount = typeof raw === 'number' ? String(raw) : raw;
349
+ if (typeof amount !== 'string' || !DECIMAL_AMOUNT.test(amount)) return { reason: 'amount_invalid' };
350
+ if (amount.startsWith('-')) return { reason: 'amount_invalid' };
351
+ if (currency == null) return { reason: 'currency_missing' };
352
+ const money = moneySchema.safeParse({
353
+ amount, amountUnit: 'major_decimal', currency, exponent: currencyExponent(currency),
354
+ });
355
+ return money.success ? { value: money.data } : { reason: 'amount_invalid' };
356
+ }
357
+
358
+ /**
359
+ * The value the graph contract accepts under the amount `key`, as { value },
360
+ * or the reason it cannot be typed. Canonical money passes through; a
361
+ * MonetaryAmount { value, currency } and a decimal string or number become
362
+ * canonical money.
363
+ */
364
+ function typeMoney(key, raw, currency) {
365
+ if (isMoneyFieldValue(key, raw)) {
366
+ if ([].concat(raw).some((money) => String(money.amount).startsWith('-'))) {
367
+ return { reason: 'amount_invalid' };
368
+ }
369
+ return { value: raw };
370
+ }
371
+ if (isSingleMonetaryAmount(raw)) {
372
+ return typeAmount(key, raw.value, currencyInScope(raw, currency));
373
+ }
374
+ return typeAmount(key, raw, currency);
375
+ }
376
+
377
+ /**
378
+ * Walk entity attributes, typing amounts as canonical money and counts as
379
+ * explicit quantities. Returns { attrs, unresolved } and does not mutate the
380
+ * input. Field names decide money exactly as the graph contract does
381
+ * (isMoneyFieldName). A value that cannot be typed honestly is left out of
382
+ * the attributes and recorded as an unresolved fact with its attribute path,
383
+ * e.g. offers[0].priceSpecification[1].price, so the rest of the graph still
384
+ * publishes.
385
+ *
386
+ * - Amounts (price, lowPrice, minPrice, salePrice, returnShippingFeesAmount,
387
+ * ...; a MonetaryAmount { value, currency }; the amounts of a larger
388
+ * MonetaryAmount) become canonical money in the currency in scope: the
389
+ * object's own priceCurrency or currency, else the enclosing object's.
390
+ * Without a known one: currency_missing. Anything that is not a decimal
391
+ * amount, such as a date (priceValidUntil), a flag (valueAddedTaxIncluded)
392
+ * or a negative amount: amount_invalid.
393
+ * - offers, priceSpecification, shippingDetails, aggregateOffer and hasVariant
394
+ * hold objects or lists of them. A bare value there, or in such a list
395
+ * (where it is nulled so later items keep their paths): not_an_object.
396
+ * - offerCount, reviewCount or ratingCount holding a whole, non-negative
397
+ * safe integer becomes an explicit count quantity.
398
+ * - Numeric identifiers (sku, gtin, gtin8, gtin12, gtin13, gtin14, mpn,
399
+ * isbn, productID, productGroupID, identifier, id) that are safe,
400
+ * non-negative integers are stringified in place; any other bare number
401
+ * (ratingValue: 4.6): untyped_number.
402
+ * - A list outside the money containers that holds a bare number or untyped
403
+ * money is recorded whole, never spliced:
404
+ * untyped_money when it holds untyped money, else untyped_number.
405
+ * - A range whose low end exceeds its high end publishes neither end:
406
+ * range_inverted.
407
+ * - Canonical money and explicit quantities pass through unchanged,
408
+ * except negative canonical money (amount_invalid).
409
+ */
410
+ function normalizeEntityAttributes(attrs) {
411
+ const MAX_DEPTH = 10;
412
+ const unresolved = [];
413
+ const record = (field, path, rawValue, reason) => unresolved.push({ field, path, rawValue, reason });
414
+ const pathOf = (parent, key) => (parent ? `${parent}.${key}` : key);
415
+
416
+ // A list under a money container holds objects. Any other item is recorded
417
+ // and nulled, so later items keep their index and path.
418
+ function walkContainerList(list, key, currency, path, depth) {
419
+ return list.map((item, i) => {
420
+ if (item === null) return item;
421
+ if (isPlainObject(item)) return walk(item, currency, `${path}[${i}]`, depth + 1);
422
+ record(key, `${path}[${i}]`, item, 'not_an_object');
423
+ return null;
424
+ });
425
+ }
426
+
427
+ function walk(obj, outerCurrency, attrPath, depth) {
428
+ if (depth > MAX_DEPTH || isTyped(obj)) return obj;
429
+ const currency = currencyInScope(obj, outerCurrency);
430
+ const result = {};
431
+
432
+ for (const key of Object.keys(obj)) {
433
+ const val = obj[key];
434
+ const path = pathOf(attrPath, key);
435
+ if (val === undefined) {
436
+ result[key] = val;
437
+ continue;
438
+ }
439
+
440
+ if (MONEY_CONTAINER_KEYS.has(key)) {
441
+ if (val === null) result[key] = val;
442
+ else if (isPlainObject(val)) result[key] = walk(val, currency, path, depth + 1);
443
+ else if (Array.isArray(val)) result[key] = walkContainerList(val, key, currency, path, depth);
444
+ else record(key, path, val, 'not_an_object');
445
+ continue;
446
+ }
447
+
448
+ if (isAmount(obj, key, val)) {
449
+ const typed = typeMoney(key, val, currency);
450
+ if (typed.reason) record(key, path, val, typed.reason);
451
+ else result[key] = typed.value;
452
+ continue;
453
+ }
454
+
455
+ if (COUNT_KEYS.has(key) && Number.isSafeInteger(val) && val >= 0) {
456
+ result[key] = { kind: 'quantity', value: val, unit: 'count' };
457
+ continue;
458
+ }
459
+
460
+ // Any other bare number (or an untrusted numeric identifier) is
461
+ // ambiguous to the graph contract: leave it out of the attributes
462
+ // so the rest of the graph still publishes.
463
+ if (typeof val === 'number') {
464
+ if (IDENTIFIER_KEYS.has(key)) {
465
+ // Only safe, non-negative integers are trustworthy identifiers.
466
+ if (Number.isSafeInteger(val) && val >= 0) {
467
+ result[key] = String(val);
468
+ continue;
469
+ }
470
+ record(key, path, val, 'untyped_number');
471
+ continue;
472
+ }
473
+ record(key, path, val, 'untyped_number');
474
+ continue;
475
+ }
476
+
477
+ // Arrays outside the money containers are not walked: one holding a
478
+ // bare number or untyped money is recorded whole rather than spliced.
479
+ if (Array.isArray(val) && (holdsBareNumber(val) || holdsUntypedMoney(val))) {
480
+ record(key, path, val, holdsUntypedMoney(val) ? 'untyped_money' : 'untyped_number');
481
+ continue;
482
+ }
483
+
484
+ result[key] = isPlainObject(val) ? walk(val, currency, path, depth + 1) : val;
485
+ }
486
+
487
+ // A range whose low end exceeds its high end publishes neither end.
488
+ for (const [low, high] of AMOUNT_RANGES) {
489
+ const lowMinor = toMinorUnits(result[low]);
490
+ const highMinor = toMinorUnits(result[high]);
491
+ if (lowMinor == null || highMinor == null || lowMinor <= highMinor ||
492
+ result[low].currency !== result[high].currency) continue;
493
+ for (const key of [low, high]) {
494
+ delete result[key];
495
+ record(key, pathOf(attrPath, key), obj[key], 'range_inverted');
496
+ }
497
+ }
498
+
499
+ return result;
500
+ }
501
+
502
+ const normalized = walk(attrs, null, '', 0);
503
+ // Unresolved facts are published in the JSON graph, which cannot carry NaN
504
+ // or Infinity (JSON.parse reads 1e999 as Infinity). Such a fact keeps its
505
+ // field, path and reason; only the unrepresentable raw value is left out.
506
+ for (const fact of unresolved) {
507
+ if (holdsNonFiniteNumber(fact.rawValue)) delete fact.rawValue;
508
+ }
509
+ return { attrs: normalized, unresolved };
510
+ }
511
+
512
+ function extractAssertions(tenantScope, sourceId, sourceVersion, capturedAt, facts, entities) {
513
+ const assertions = [];
514
+ if (!facts) return assertions;
515
+
516
+ const add = (subject, predicate, object, opts = {}) => {
517
+ const id = assertionId(sourceId, assertions.length);
518
+ assertions.push({
519
+ id,
520
+ subject,
521
+ predicate,
522
+ object,
523
+ provenance: opts.provenance || 'observed',
524
+ schemaValidity: 'unvalidated',
525
+ modelConfidence: 'unavailable',
526
+ freshness: { asOf: capturedAt },
527
+ source: { sourceId, version: sourceVersion, capturedAt, tenantScope },
528
+ ...(opts.evidenceRef ? { evidenceRef: opts.evidenceRef } : {}),
529
+ });
530
+ };
531
+
532
+ const canonicalStr = typeof facts.canonical === 'string' ? facts.canonical : null;
533
+ const pageId = canonicalStr
534
+ ? pageEntityId(tenantScope, canonicalStr)
535
+ : pageEntityId(tenantScope, sourceId);
536
+
537
+ if (pageId) {
538
+ const pageExists = entities.some((e) => e.id === pageId);
539
+ if (!pageExists) {
540
+ entities.push({
541
+ id: pageId,
542
+ type: ENTITY_KIND.PAGE,
543
+ label: facts.title || undefined,
544
+ provenance: 'observed',
545
+ tenantScope,
546
+ sourceId,
547
+ sourceVersion,
548
+ capturedAt,
549
+ });
550
+ }
551
+ }
552
+
553
+ if (pageId) {
554
+ for (const entity of entities) {
555
+ if (entity.type === ENTITY_KIND.PAGE) continue;
556
+ add(pageId, EW05_PREDICATES.DESCRIBES, { entityId: entity.id }, { provenance: 'derived' });
557
+ }
558
+ }
559
+
560
+ // Entity attribute assertions: create assertions for each entity attribute
561
+ // so that conflicting attribute values from different sources are detectable.
562
+ for (const entity of entities) {
563
+ if (!entity.id || !entity.attributes) continue;
564
+ for (const [attrKey, attrVal] of Object.entries(entity.attributes)) {
565
+ if (attrVal === undefined || attrVal === null) continue;
566
+ const attrPredicate = `${EW05_PREDICATES.DESCRIBES}_${attrKey}`;
567
+ // Canonical money objects pass through as-is; other values are
568
+ // stringified, unless the graph contract would read the string as an
569
+ // untyped price: a price specification or a list of money stays JSON.
570
+ const text = String(attrVal).slice(0, 1000);
571
+ const assertionObject = isMoney(attrVal) ||
572
+ (typeof attrVal === 'object' && isMoneyFieldName(attrPredicate, text))
573
+ ? attrVal
574
+ : text;
575
+ add(entity.id, attrPredicate, assertionObject, { provenance: 'observed' });
576
+ }
577
+ }
578
+
579
+ for (const heading of facts.headings || []) {
580
+ if (pageId && heading && typeof heading === 'object' && heading !== null && typeof heading.text === 'string' && heading.text) {
581
+ // Heading level encoded in text to avoid numeric type issues
582
+ const level = Number(heading.level);
583
+ if (!Number.isSafeInteger(level) || level < 1 || level > 6) continue;
584
+ const headingLevel = `h${level}`;
585
+ add(pageId, EW05_PREDICATES.CONTAINS, {
586
+ kind: headingLevel,
587
+ text: heading.text,
588
+ });
589
+ }
590
+ }
591
+
592
+ for (const link of facts.links || []) {
593
+ if (!link.href) continue;
594
+ const targetUrl = normaliseUrl(link.href);
595
+ if (!targetUrl) continue;
596
+ const targetPageId = pageEntityId(tenantScope, targetUrl);
597
+ if (targetPageId && pageId && targetPageId !== pageId) {
598
+ add(pageId, EW05_PREDICATES.REFERENCES, {
599
+ entityId: targetPageId,
600
+ url: targetUrl,
601
+ text: link.text || undefined,
602
+ });
603
+ }
604
+ }
605
+
606
+ return assertions;
607
+ }
608
+
609
+ function mergeEntities(entityLists) {
610
+ const map = new Map();
611
+ for (const list of entityLists) {
612
+ for (const entity of list) {
613
+ const existing = map.get(entity.id);
614
+ if (!existing) {
615
+ map.set(entity.id, { ...entity });
616
+ } else {
617
+ mergeTwoEntities(existing, entity);
618
+ }
619
+ }
620
+ }
621
+
622
+ // Identifier-first: entities with the same @id-based key are already
623
+ // merged by deterministic id in the first pass. No second-pass digest
624
+ // merge is needed — name-only matches remain separate (see §EW05 (3)).
625
+ const idRemap = new Map();
626
+ return { entities: [...map.values()], idRemap };
627
+ }
628
+
629
+ function mergeTwoEntities(existing, entity) {
630
+ const existingRank = provenanceRank(existing.provenance);
631
+ const newRank = provenanceRank(entity.provenance);
632
+
633
+ // Merge label: keep higher-provenance label, fall back to existing
634
+ if (!existing.label && entity.label) {
635
+ existing.label = entity.label;
636
+ } else if (newRank >= existingRank && entity.label) {
637
+ existing.label = entity.label;
638
+ }
639
+
640
+ // If new has higher provenance, update scalar fields (type, provenance).
641
+ // Never downgrade a known type to UNKNOWN.
642
+ if (newRank >= existingRank) {
643
+ if (entity.type !== ENTITY_KIND.UNKNOWN || existing.type === ENTITY_KIND.UNKNOWN) {
644
+ existing.type = entity.type;
645
+ }
646
+ existing.provenance = entity.provenance;
647
+ }
648
+
649
+ // Per-attribute merge: merge attributes objects by key
650
+ if (entity.attributes && Object.keys(entity.attributes).length > 0) {
651
+ if (!existing.attributes) existing.attributes = {};
652
+ for (const [attrKey, attrVal] of Object.entries(entity.attributes)) {
653
+ if (attrVal === undefined || attrVal === null) continue;
654
+ if (!(attrKey in existing.attributes)) {
655
+ // New attribute: adopt it
656
+ existing.attributes[attrKey] = attrVal;
657
+ } else if (existing.attributes[attrKey] !== attrVal) {
658
+ // Conflicting value: retain both as an array of source-attributed values
659
+ const prev = existing.attributes[attrKey];
660
+ if (Array.isArray(prev)) {
661
+ if (!prev.some((v) => typeof v === 'object' && v.value === attrVal)) {
662
+ prev.push({ value: attrVal, provenance: entity.provenance, sourceId: entity.sourceId });
663
+ }
664
+ } else {
665
+ existing.attributes[attrKey] = [
666
+ { value: prev, provenance: existing.provenance, sourceId: existing.sourceId },
667
+ { value: attrVal, provenance: entity.provenance, sourceId: entity.sourceId },
668
+ ];
669
+ }
670
+ }
671
+ }
672
+ }
673
+ }
674
+
675
+ function provenanceRank(p) {
676
+ if (p === 'authoritative') return 3;
677
+ if (p === 'derived') return 2;
678
+ if (p === 'observed') return 1;
679
+ return 0;
680
+ }
681
+
682
+ // ---------------------------------------------------------------------------
683
+ // Contradiction detection
684
+ // ---------------------------------------------------------------------------
685
+
686
+ function detectContradictions(assertions) {
687
+ // Only single-valued attribute assertions can produce contradictions.
688
+ // Multi-valued predicates (CONTAINS, REFERENCES, DESCRIBES) are expected
689
+ // to have multiple values and must not generate false contradictions.
690
+ function isSingleValued(predicate) {
691
+ // Attribute assertions: describes_name, describes_sku, etc.
692
+ return predicate.startsWith(EW05_PREDICATES.DESCRIBES + '_');
693
+ }
694
+
695
+ const byKey = new Map();
696
+ for (const a of assertions) {
697
+ if (!isSingleValued(a.predicate)) continue;
698
+ const key = `${a.subject}|${a.predicate}`;
699
+ if (!byKey.has(key)) byKey.set(key, []);
700
+ byKey.get(key).push(a);
701
+ }
702
+
703
+ const contradictions = [];
704
+ for (const [, group] of byKey) {
705
+ if (group.length < 2) continue;
706
+ const objects = group.map((a) => JSON.stringify(a.object));
707
+ const unique = new Set(objects);
708
+ if (unique.size > 1) {
709
+ contradictions.push({
710
+ key: `${group[0].subject}|${group[0].predicate}`,
711
+ assertionIds: group.map((a) => a.id),
712
+ sourceIds: [...new Set(group.map((a) => a.source.sourceId))],
713
+ objects: group.map((a) => a.object),
714
+ });
715
+ }
716
+ }
717
+
718
+ return contradictions;
719
+ }
720
+
721
+ // ---------------------------------------------------------------------------
722
+ // Cycle detection in link graph
723
+ // ---------------------------------------------------------------------------
724
+
725
+ function detectLinkCycles(links) {
726
+ const adjacency = new Map();
727
+ for (const link of links) {
728
+ if (!adjacency.has(link.source)) adjacency.set(link.source, []);
729
+ adjacency.get(link.source).push(link.target);
730
+ }
731
+
732
+ const GRAY = 1;
733
+ const BLACK = 2;
734
+ const color = new Map();
735
+ const cycles = [];
736
+
737
+ function dfs(node, path) {
738
+ color.set(node, GRAY);
739
+ const neighbors = adjacency.get(node) || [];
740
+ for (const next of neighbors) {
741
+ if (color.get(next) === GRAY) {
742
+ const cycleStart = path.indexOf(next);
743
+ if (cycleStart !== -1) {
744
+ cycles.push(path.slice(cycleStart).concat(next));
745
+ }
746
+ } else if (color.get(next) !== BLACK) {
747
+ dfs(next, path.concat(next));
748
+ }
749
+ }
750
+ color.set(node, BLACK);
751
+ }
752
+
753
+ for (const node of adjacency.keys()) {
754
+ if (!color.has(node)) {
755
+ dfs(node, [node]);
756
+ }
757
+ }
758
+
759
+ return cycles;
760
+ }
761
+
762
+ // ---------------------------------------------------------------------------
763
+ // Link dedup key (internal, not stored on link object)
764
+ // ---------------------------------------------------------------------------
765
+
766
+ function linkDedupKey(source, target, predicate) {
767
+ return `${source}|${target}|${predicate}`;
768
+ }
769
+
770
+ // ---------------------------------------------------------------------------
771
+ // Compilation — main entry point
772
+ // ---------------------------------------------------------------------------
773
+
774
+ /**
775
+ * Extract all data from snapshots into entities, assertions, and links.
776
+ */
777
+ async function extractFromSnapshots(snapshots, factsProvider) {
778
+ const allEntities = [];
779
+ const allAssertions = [];
780
+ const allLinks = [];
781
+ const allUnresolvedFacts = [];
782
+
783
+ for (const snap of snapshots) {
784
+ let facts;
785
+ if (typeof factsProvider === 'function') {
786
+ try {
787
+ facts = await factsProvider(snap);
788
+ } catch {
789
+ facts = null;
790
+ }
791
+ }
792
+
793
+ const { entities, unresolvedFacts: snapUnresolved } = extractEntities(
794
+ snap.tenantScope, snap.sourceId, snap.version, snap.capturedAt, facts,
795
+ );
796
+ if (snapUnresolved && snapUnresolved.length > 0) {
797
+ for (const uf of snapUnresolved) {
798
+ uf.sourceId = snap.sourceId;
799
+ uf.tenantScope = snap.tenantScope;
800
+ }
801
+ allUnresolvedFacts.push(...snapUnresolved);
802
+ }
803
+ const assertions = extractAssertions(
804
+ snap.tenantScope, snap.sourceId, snap.version, snap.capturedAt, facts, entities,
805
+ );
806
+
807
+ const canonicalStr2 = facts && typeof facts.canonical === 'string' ? facts.canonical : null;
808
+ const pageId = canonicalStr2
809
+ ? pageEntityId(snap.tenantScope, canonicalStr2)
810
+ : pageEntityId(snap.tenantScope, snap.sourceId);
811
+
812
+ // DESCRIBES links: page → entity
813
+ if (pageId) {
814
+ for (const entity of entities) {
815
+ if (entity.type === ENTITY_KIND.PAGE) continue;
816
+ allLinks.push({
817
+ source: pageId,
818
+ target: entity.id,
819
+ predicate: EW05_PREDICATES.DESCRIBES,
820
+ provenance: 'derived',
821
+ confidence: 'medium',
822
+ tenantScope: snap.tenantScope,
823
+ sourceId: snap.sourceId,
824
+ sourceVersion: snap.version,
825
+ capturedAt: snap.capturedAt,
826
+ });
827
+ }
828
+ }
829
+
830
+ // REFERENCES links: page → other page
831
+ if (pageId) {
832
+ for (const link of facts?.links || []) {
833
+ if (!link.href) continue;
834
+ const targetUrl = normaliseUrl(link.href);
835
+ if (!targetUrl) continue;
836
+ const targetPageId = pageEntityId(snap.tenantScope, targetUrl);
837
+ if (targetPageId && targetPageId !== pageId) {
838
+ // Ensure the target page entity exists (as a placeholder if not yet seen).
839
+ // Placeholders are marked unresolved and must not carry anchor text as label.
840
+ if (!entities.some((e) => e.id === targetPageId)) {
841
+ entities.push({
842
+ id: targetPageId,
843
+ type: ENTITY_KIND.PAGE,
844
+ provenance: 'unresolved',
845
+ tenantScope: snap.tenantScope,
846
+ sourceId: snap.sourceId,
847
+ sourceVersion: snap.version,
848
+ capturedAt: snap.capturedAt,
849
+ });
850
+ }
851
+ allLinks.push({
852
+ source: pageId,
853
+ target: targetPageId,
854
+ predicate: EW05_PREDICATES.REFERENCES,
855
+ provenance: 'observed',
856
+ confidence: 'medium',
857
+ tenantScope: snap.tenantScope,
858
+ sourceId: snap.sourceId,
859
+ sourceVersion: snap.version,
860
+ capturedAt: snap.capturedAt,
861
+ });
862
+ }
863
+ }
864
+ }
865
+
866
+ allEntities.push(entities);
867
+ allAssertions.push(assertions);
868
+ }
869
+
870
+ return { allEntities, allAssertions, allLinks, allUnresolvedFacts };
871
+ }
872
+
873
+ /**
874
+ * Build the final graph from extracted data.
875
+ */
876
+ function buildGraph(allEntities, allAssertions, allLinks, allUnresolvedFacts) {
877
+ const { entities: mergedEntities, idRemap } = mergeEntities(allEntities);
878
+ const mergedAssertions = allAssertions.flat();
879
+
880
+ // Deduplicate links and remap targets after identifier-first merge
881
+ const linkMap = new Map();
882
+ for (const link of allLinks) {
883
+ // Remap link source/target after identifier-first entity merge
884
+ const remappedLink = { ...link };
885
+ if (idRemap.has(link.source)) remappedLink.source = idRemap.get(link.source);
886
+ if (idRemap.has(link.target)) remappedLink.target = idRemap.get(link.target);
887
+ const key = linkDedupKey(remappedLink.source, remappedLink.target, remappedLink.predicate);
888
+ if (!linkMap.has(key)) {
889
+ linkMap.set(key, remappedLink);
890
+ }
891
+ }
892
+ const mergedLinks = [...linkMap.values()];
893
+
894
+ // same_as_candidate links: entities with matching normalized names
895
+ // but different identity keys (name-only matches across sources)
896
+ const byNormalizedName = new Map();
897
+ for (const entity of mergedEntities) {
898
+ if (entity.type === ENTITY_KIND.PAGE) continue;
899
+ const normalized = String(entity.label ?? '').toLowerCase().trim();
900
+ if (!normalized) continue;
901
+ if (!byNormalizedName.has(normalized)) {
902
+ byNormalizedName.set(normalized, []);
903
+ }
904
+ byNormalizedName.get(normalized).push(entity);
905
+ }
906
+ for (const [, group] of byNormalizedName) {
907
+ if (group.length < 2) continue;
908
+ // Create same_as_candidate links between all pairs
909
+ for (let i = 0; i < group.length; i++) {
910
+ for (let j = i + 1; j < group.length; j++) {
911
+ const key = linkDedupKey(group[i].id, group[j].id, EW05_PREDICATES.SAME_AS_CANDIDATE);
912
+ if (!linkMap.has(key)) {
913
+ linkMap.set(key, {
914
+ source: group[i].id,
915
+ target: group[j].id,
916
+ predicate: EW05_PREDICATES.SAME_AS_CANDIDATE,
917
+ provenance: 'derived',
918
+ confidence: 'low',
919
+ tenantScope: group[i].tenantScope || group[j].tenantScope,
920
+ sourceId: group[i].sourceId,
921
+ sourceVersion: group[i].sourceVersion,
922
+ capturedAt: group[i].capturedAt,
923
+ });
924
+ }
925
+ }
926
+ }
927
+ }
928
+
929
+ // Rebuild mergedLinks after adding same_as_candidate links
930
+ const finalLinks = [...linkMap.values()];
931
+
932
+ const contradictions = detectContradictions(mergedAssertions);
933
+ const cycles = detectLinkCycles(finalLinks);
934
+
935
+ // Deterministic: use latest snapshot timestamp, not new Date()
936
+ let generatedAt = new Date(0).toISOString();
937
+ for (const a of mergedAssertions) {
938
+ if (a.source?.capturedAt && a.source.capturedAt > generatedAt) {
939
+ generatedAt = a.source.capturedAt;
940
+ }
941
+ }
942
+ if (generatedAt === new Date(0).toISOString()) {
943
+ // Fallback for assertions without timestamps
944
+ for (const e of mergedEntities) {
945
+ if (e.capturedAt && e.capturedAt > generatedAt) generatedAt = e.capturedAt;
946
+ }
947
+ }
948
+ const graph = {
949
+ version: GRAPH_SCHEMA_VERSION,
950
+ entities: mergedEntities.length > 0 ? mergedEntities : undefined,
951
+ links: finalLinks.length > 0 ? finalLinks : undefined,
952
+ assertions: mergedAssertions,
953
+ unresolvedFacts: allUnresolvedFacts && allUnresolvedFacts.length > 0 ? allUnresolvedFacts : undefined,
954
+ coverage: {
955
+ totalAssertions: mergedAssertions.length,
956
+ byProvenance: countBy(mergedAssertions, 'provenance'),
957
+ bySchemaValidity: countBy(mergedAssertions, 'schemaValidity'),
958
+ },
959
+ generatedAt,
960
+ };
961
+
962
+ return { graph, contradictions, cycles, mergedEntities, mergedAssertions, finalLinks, allUnresolvedFacts };
963
+ }
964
+
965
+ async function _compile({
966
+ graphStore,
967
+ caller,
968
+ namespace,
969
+ factsProvider,
970
+ publish = false,
971
+ } = {}) {
972
+ if (!graphStore || typeof graphStore.listSourceSnapshots !== 'function') {
973
+ throw new CompileGraphError('graphStore with listSourceSnapshots is required');
974
+ }
975
+ if (!caller || typeof caller !== 'object') {
976
+ throw new CompileGraphError('caller identity is required');
977
+ }
978
+ if (!namespace || typeof namespace.raw !== 'string') {
979
+ throw new CompileGraphError('namespace is required');
980
+ }
981
+
982
+ const snapshots = await graphStore.listSourceSnapshots(caller, namespace);
983
+ if (!Array.isArray(snapshots) || snapshots.length === 0) {
984
+ throw new CompileGraphError('no source snapshots to compile');
985
+ }
986
+
987
+ const { allEntities, allAssertions, allLinks, allUnresolvedFacts } = await extractFromSnapshots(snapshots, factsProvider);
988
+ const { graph, contradictions, cycles, mergedEntities, mergedAssertions, finalLinks } =
989
+ buildGraph(allEntities, allAssertions, allLinks, allUnresolvedFacts);
990
+
991
+ const schemaResult = graphSchema.safeParse(graph);
992
+ if (!schemaResult.success && publish) {
993
+ const messages = schemaResult.error.issues.map((i) => i.path.join('.') + ': ' + i.message);
994
+ throw new CompileGraphError('Compiled graph invalid: ' + messages.join('; '));
995
+ }
996
+
997
+ if (publish) {
998
+ const published = await graphStore.publishGraph(caller, namespace, schemaResult.data);
999
+ return {
1000
+ graph: published.graph,
1001
+ version: published.version,
1002
+ publishedAt: published.publishedAt,
1003
+ contentHash: published.contentHash,
1004
+ entities: mergedEntities,
1005
+ links: finalLinks,
1006
+ assertions: mergedAssertions,
1007
+ contradictions,
1008
+ cycles,
1009
+ unresolvedFacts: allUnresolvedFacts,
1010
+ entityCount: mergedEntities.length,
1011
+ assertionCount: mergedAssertions.length,
1012
+ linkCount: finalLinks.length,
1013
+ sourceCount: snapshots.length,
1014
+ };
1015
+ }
1016
+
1017
+ return {
1018
+ status: schemaResult.success ? GENERATION_STATUS.CANDIDATE : 'invalid',
1019
+ graph: schemaResult.success ? schemaResult.data : graph,
1020
+ schemaErrors: schemaResult.success ? [] : schemaResult.error.issues,
1021
+ contradictions,
1022
+ cycles,
1023
+ unresolvedFacts: allUnresolvedFacts,
1024
+ entityCount: mergedEntities.length,
1025
+ assertionCount: mergedAssertions.length,
1026
+ linkCount: finalLinks.length,
1027
+ sourceCount: snapshots.length,
1028
+ };
1029
+ }
1030
+
1031
+ export async function compileGraph(opts = {}) {
1032
+ return _compile({ ...opts, publish: true });
1033
+ }
1034
+
1035
+ export async function compileCandidate(opts = {}) {
1036
+ return _compile({ ...opts, publish: false });
1037
+ }
1038
+
1039
+ // ---------------------------------------------------------------------------
1040
+ // Helpers
1041
+ // ---------------------------------------------------------------------------
1042
+
1043
+ function countBy(items, field) {
1044
+ const counts = {};
1045
+ for (const item of items) {
1046
+ const val = item[field];
1047
+ if (val !== undefined) {
1048
+ counts[val] = (counts[val] || 0) + 1;
1049
+ }
1050
+ }
1051
+ return counts;
1052
+ }
1053
+
1054
+ // ---------------------------------------------------------------------------
1055
+ // Error type
1056
+ // ---------------------------------------------------------------------------
1057
+
1058
+ export class CompileGraphError extends Error {
1059
+ constructor(message) {
1060
+ super(message);
1061
+ this.name = 'CompileGraphError';
1062
+ }
1063
+ }