rcs-context 0.0.0-stage → 0.1.39
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/LICENSE.txt +21 -0
- package/README.md +194 -2
- package/condense/chunk.mjs +258 -0
- package/condense/ivr.mjs +104 -0
- package/contextinjector-webui/README.md +43 -0
- package/contextinjector-webui/lib/client.js +3034 -0
- package/contextinjector-webui/lib/index.mjs +1565 -0
- package/contextinjector-webui/lib/trace-read.mjs +180 -0
- package/contextinjector-webui/package.json +21 -0
- package/cordis.patch.yml +25 -0
- package/package.json +66 -4
- package/toolfold/toolfold.mjs +513 -0
- package/web/contextinjector.mjs +3139 -0
- package/web/extreason.mjs +1107 -0
- package/web/ledger-online.mjs +229 -0
- package/web/logcompiler.mjs +369 -0
- package/web/memory-durability.mjs +431 -0
- package/web/memory-janitor.mjs +551 -0
- package/web/memory-ledger-core.mjs +1331 -0
- package/web/memory-ledger.mjs +836 -0
- package/web/memory-observations.mjs +430 -0
- package/web/memory-online-bridge.mjs +688 -0
- package/web/memory-proposal-store.mjs +1054 -0
- package/web/memory-proposals.mjs +2306 -0
- package/web/memory-trace.mjs +533 -0
- package/web/memory-vec.mjs +241 -0
- package/web/memory.mjs +5801 -0
|
@@ -0,0 +1,1331 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
function encodeCanonical(value, ancestors) {
|
|
4
|
+
if (value === null) return 'null'
|
|
5
|
+
|
|
6
|
+
switch (typeof value) {
|
|
7
|
+
case 'string':
|
|
8
|
+
return JSON.stringify(value.normalize('NFC'))
|
|
9
|
+
case 'boolean':
|
|
10
|
+
return value ? 'true' : 'false'
|
|
11
|
+
case 'number':
|
|
12
|
+
if (!Number.isFinite(value)) throw new TypeError('canonical JSON rejects non-finite numbers')
|
|
13
|
+
return Object.is(value, -0) ? '0' : JSON.stringify(value)
|
|
14
|
+
case 'undefined':
|
|
15
|
+
case 'bigint':
|
|
16
|
+
case 'function':
|
|
17
|
+
case 'symbol':
|
|
18
|
+
throw new TypeError(`canonical JSON rejects ${typeof value}`)
|
|
19
|
+
case 'object':
|
|
20
|
+
break
|
|
21
|
+
default:
|
|
22
|
+
throw new TypeError('canonical JSON rejects unknown value type')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (ancestors.has(value)) throw new TypeError('canonical JSON rejects cyclic objects')
|
|
26
|
+
ancestors.add(value)
|
|
27
|
+
try {
|
|
28
|
+
if (Array.isArray(value)) {
|
|
29
|
+
return `[${Array.from(value, (item) => encodeCanonical(item, ancestors)).join(',')}]`
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const prototype = Object.getPrototypeOf(value)
|
|
33
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
34
|
+
throw new TypeError('canonical JSON accepts only plain objects and arrays')
|
|
35
|
+
}
|
|
36
|
+
if (Object.getOwnPropertySymbols(value).length > 0) {
|
|
37
|
+
throw new TypeError('canonical JSON rejects symbol keys')
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const keys = Object.keys(value)
|
|
41
|
+
if (keys.length === 1 && keys[0] === '$set') {
|
|
42
|
+
if (!Array.isArray(value.$set)) throw new TypeError('canonical set wrapper requires an array')
|
|
43
|
+
const elements = Array.from(value.$set, (item) => encodeCanonical(item, ancestors)).sort()
|
|
44
|
+
return `{"$set":[${elements.join(',')}]}`
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const entries = keys.map((key) => [key.normalize('NFC'), value[key]])
|
|
48
|
+
entries.sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)
|
|
49
|
+
for (let index = 1; index < entries.length; index++) {
|
|
50
|
+
if (entries[index - 1][0] === entries[index][0]) {
|
|
51
|
+
throw new TypeError('canonical JSON rejects key collisions after NFC normalization')
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return `{${entries.map(([key, item]) =>
|
|
55
|
+
`${JSON.stringify(key)}:${encodeCanonical(item, ancestors)}`).join(',')}}`
|
|
56
|
+
} finally {
|
|
57
|
+
ancestors.delete(value)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function canonicalJson(value) {
|
|
62
|
+
return encodeCanonical(value, new Set())
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function sha256Canonical(value) {
|
|
66
|
+
return createHash('sha256').update(canonicalJson(value), 'utf8').digest('hex')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const fileContract = {
|
|
70
|
+
allowedSources: ['observation', 'probe'],
|
|
71
|
+
cardinality: 'single',
|
|
72
|
+
mutable: true,
|
|
73
|
+
entityBound: true,
|
|
74
|
+
invalidationRule: 'observation-or-entity-generation',
|
|
75
|
+
conflictRule: 'causal-order',
|
|
76
|
+
injectable: true,
|
|
77
|
+
// Deterministic facts keep their historical observation/probe ordering. Their
|
|
78
|
+
// conflict rule is causal order, so this list is documentation of the static
|
|
79
|
+
// rank rather than an input to a value conflict.
|
|
80
|
+
sourceAuthorityOrder: ['observation', 'probe'],
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const projectContract = {
|
|
84
|
+
// `llm` is the only non-user source a project fact may carry, and only through
|
|
85
|
+
// the proposal engine: deterministic quote validation derives the value from an
|
|
86
|
+
// archived user message and stores `verification: 'derived'`. File and synthetic
|
|
87
|
+
// predicates still refuse it, and rendering keeps refusing non-injectable
|
|
88
|
+
// verification, so a hand-written unverified llm assertion stays queryable but
|
|
89
|
+
// never enters injected context.
|
|
90
|
+
allowedSources: ['user', 'llm'],
|
|
91
|
+
// Project rules are independent facts, not alternative values of one setting:
|
|
92
|
+
// "金额用整数分" and "测试用 node:test" must coexist. Each rule gets a stable
|
|
93
|
+
// valueKey (the exact rule value for an assert, the target's key for a
|
|
94
|
+
// correction/retraction), so equal-authority conflicts are scoped to the same
|
|
95
|
+
// rule rather than to the whole predicate. The slot is the exact rule value, not
|
|
96
|
+
// a semantic topic: changing a setting's value must use correct/retract; two
|
|
97
|
+
// plain asserts of the same topic remain separate rules until a semantic slot
|
|
98
|
+
// extractor is added.
|
|
99
|
+
cardinality: 'multi',
|
|
100
|
+
mutable: true,
|
|
101
|
+
entityBound: false,
|
|
102
|
+
invalidationRule: 'explicit-correction-or-retraction',
|
|
103
|
+
conflictRule: 'equal-authority-conflict',
|
|
104
|
+
// Static, registry-declared authority. The model can never declare it. A user
|
|
105
|
+
// statement outranks an LLM proposal that merely restates the same archived
|
|
106
|
+
// message, so a lower-authority duplicate can neither conflict out the user fact
|
|
107
|
+
// nor render a second copy of it.
|
|
108
|
+
sourceAuthorityOrder: ['user', 'llm'],
|
|
109
|
+
injectable: true,
|
|
110
|
+
valueSchema: {
|
|
111
|
+
type: 'object',
|
|
112
|
+
required: ['text'],
|
|
113
|
+
properties: { text: { type: 'string', minLength: 1, nonBlank: true } },
|
|
114
|
+
},
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export const ASSERTION_OPERATIONS = new Set(['assert', 'correct', 'retract'])
|
|
118
|
+
export const VERIFICATION_VALUES = new Set([
|
|
119
|
+
'observed', 'user-confirmed', 'derived', 'unverified', 'conflicted',
|
|
120
|
+
])
|
|
121
|
+
// Only evidence-backed verification may enter automatic shadow injection in Cycle 1.
|
|
122
|
+
// `unverified` and `conflicted` stay queryable for explicit audit but never render.
|
|
123
|
+
export const INJECTABLE_VERIFICATIONS = new Set(['observed', 'user-confirmed', 'derived'])
|
|
124
|
+
|
|
125
|
+
// LLM facts may only describe project semantics, and only through the proposal
|
|
126
|
+
// engine's exact-quote evidence object. The ledger stores no wall clock or model
|
|
127
|
+
// text in that evidence: proposalId/eventKey/start/end/quote/model/schemaVersion are
|
|
128
|
+
// the complete, closed set.
|
|
129
|
+
export const LLM_PREDICATES = new Set([
|
|
130
|
+
'project.decision', 'project.constraint', 'project.preference',
|
|
131
|
+
])
|
|
132
|
+
export const LLM_EVIDENCE_FIELDS = new Set([
|
|
133
|
+
'proposalId', 'eventKey', 'start', 'end', 'quote', 'model', 'modelDigest', 'schemaVersion',
|
|
134
|
+
])
|
|
135
|
+
|
|
136
|
+
// Route capability is never a stored flag: enablement is recomputed from the
|
|
137
|
+
// evidence counters on every read, so a hand-edited row cannot turn a route on.
|
|
138
|
+
// It lives in the core because both the journal and the ledger read gate depend on
|
|
139
|
+
// the exact same thresholds, and because the counters themselves are bound to the
|
|
140
|
+
// evaluation evidence that produced them.
|
|
141
|
+
export const ROUTE_SAMPLE_FLOOR = 20
|
|
142
|
+
export const ROUTE_PRECISION_FLOOR = 0.95
|
|
143
|
+
export const ROUTE_RECALL_FLOOR = 0.5
|
|
144
|
+
|
|
145
|
+
export function routeEnabled({ sampleCount, precision, recall, safetyFailures } = {}) {
|
|
146
|
+
return Number.isInteger(sampleCount) && sampleCount >= ROUTE_SAMPLE_FLOOR &&
|
|
147
|
+
typeof precision === 'number' && Number.isFinite(precision) &&
|
|
148
|
+
precision >= ROUTE_PRECISION_FLOOR &&
|
|
149
|
+
typeof recall === 'number' && Number.isFinite(recall) &&
|
|
150
|
+
recall >= ROUTE_RECALL_FLOOR &&
|
|
151
|
+
safetyFailures === 0
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function hexSha256(value) {
|
|
155
|
+
return typeof value === 'string' && /^[0-9a-f]{64}$/.test(value)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function nonNegativeInteger(value) {
|
|
159
|
+
return Number.isInteger(value) && value >= 0
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// A route row is only executable when its stored counters are the exact arithmetic
|
|
163
|
+
// result of the evaluation evidence that accompanies it. Nothing here trusts a
|
|
164
|
+
// caller-supplied precision/recall pair: `sampleCount` must equal the number of
|
|
165
|
+
// positive labels, precision and recall are recomputed from tp/fp/fn, and the
|
|
166
|
+
// safety counter must agree with the evidence.
|
|
167
|
+
export function routeEvidenceValid(evidence) {
|
|
168
|
+
if (!evidence || typeof evidence !== 'object' || Array.isArray(evidence)) return false
|
|
169
|
+
if (!hexSha256(evidence.corpusSha256) || !hexSha256(evidence.evaluatorSha256)) return false
|
|
170
|
+
if (typeof evidence.modelDigest !== 'string' || evidence.modelDigest.trim() === '') return false
|
|
171
|
+
if (!nonNegativeInteger(evidence.tp) || !nonNegativeInteger(evidence.fp) ||
|
|
172
|
+
!nonNegativeInteger(evidence.fn) || !nonNegativeInteger(evidence.positiveLabels) ||
|
|
173
|
+
!nonNegativeInteger(evidence.safetyFailures)) {
|
|
174
|
+
return false
|
|
175
|
+
}
|
|
176
|
+
if (!Number.isFinite(evidence.measuredAt)) return false
|
|
177
|
+
if (evidence.sampleCount !== undefined && evidence.sampleCount !== evidence.positiveLabels) {
|
|
178
|
+
return false
|
|
179
|
+
}
|
|
180
|
+
if (evidence.tp + evidence.fn !== evidence.positiveLabels) return false
|
|
181
|
+
if (evidence.tp + evidence.fp === 0) return false
|
|
182
|
+
const precision = evidence.tp / (evidence.tp + evidence.fp)
|
|
183
|
+
const recall = evidence.tp / (evidence.tp + evidence.fn)
|
|
184
|
+
if (evidence.precision !== undefined && evidence.precision !== precision) return false
|
|
185
|
+
if (evidence.recall !== recall) return false
|
|
186
|
+
return true
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function routeRowValid({
|
|
190
|
+
sampleCount, precision, recall, safetyFailures, evidence, modelDigest,
|
|
191
|
+
} = {}) {
|
|
192
|
+
if (typeof modelDigest !== 'string' || modelDigest.trim() === '') return false
|
|
193
|
+
if (!routeEvidenceValid(evidence)) return false
|
|
194
|
+
if (evidence.modelDigest !== modelDigest) return false
|
|
195
|
+
if (sampleCount !== evidence.positiveLabels) return false
|
|
196
|
+
if (safetyFailures !== evidence.safetyFailures) return false
|
|
197
|
+
const derivedPrecision = evidence.tp / (evidence.tp + evidence.fp)
|
|
198
|
+
if (precision !== derivedPrecision || recall !== evidence.recall) return false
|
|
199
|
+
return routeEnabled({ sampleCount, precision, recall, safetyFailures })
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Static authority rank from the predicate registry. Lower rank is higher
|
|
203
|
+
// authority; an unknown source is lower than every declared source, and a
|
|
204
|
+
// predicate with no declared order returns null (no static ordering).
|
|
205
|
+
export function sourceAuthorityRank(predicate, sourceKind) {
|
|
206
|
+
const contract = typeof predicate === 'string' ? PREDICATES[predicate] : predicate
|
|
207
|
+
const order = contract?.sourceAuthorityOrder
|
|
208
|
+
if (!Array.isArray(order) || order.length === 0) return null
|
|
209
|
+
const index = order.indexOf(sourceKind)
|
|
210
|
+
return index === -1 ? order.length : index
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// A project fact is duplicated by a lower-authority claim when the claim's exact
|
|
214
|
+
// text either equals or contains the higher-authority text. Containment covers the
|
|
215
|
+
// common model behaviour of quoting the whole sentence around an existing
|
|
216
|
+
// constraint; both forms add no information and must not render twice.
|
|
217
|
+
export function duplicatesHigherAuthorityValue(predicate, incumbent, candidate) {
|
|
218
|
+
const incumbentRank = sourceAuthorityRank(predicate, incumbent?.sourceKind)
|
|
219
|
+
const candidateRank = sourceAuthorityRank(predicate, candidate?.sourceKind)
|
|
220
|
+
if (incumbentRank === null || candidateRank === null) return false
|
|
221
|
+
if (candidateRank <= incumbentRank) return false
|
|
222
|
+
const incumbentText = incumbent?.value?.text
|
|
223
|
+
const candidateText = candidate?.value?.text
|
|
224
|
+
return typeof incumbentText === 'string' && incumbentText !== '' &&
|
|
225
|
+
typeof candidateText === 'string' &&
|
|
226
|
+
(candidateText === incumbentText || candidateText.includes(incumbentText))
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function matchesValueSchema(schema, value) {
|
|
230
|
+
if (!schema || typeof schema !== 'object') return false
|
|
231
|
+
switch (schema.type) {
|
|
232
|
+
case 'boolean':
|
|
233
|
+
return typeof value === 'boolean'
|
|
234
|
+
case 'number':
|
|
235
|
+
return typeof value === 'number' && Number.isFinite(value) &&
|
|
236
|
+
(schema.minimum === undefined || value >= schema.minimum)
|
|
237
|
+
case 'string':
|
|
238
|
+
if (typeof value !== 'string') return false
|
|
239
|
+
if (schema.minLength !== undefined && value.length < schema.minLength) return false
|
|
240
|
+
if (schema.nonBlank === true && value.trim() === '') return false
|
|
241
|
+
if (schema.pattern !== undefined && !new RegExp(schema.pattern).test(value)) return false
|
|
242
|
+
return true
|
|
243
|
+
case 'object': {
|
|
244
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return false
|
|
245
|
+
for (const key of schema.required ?? []) {
|
|
246
|
+
if (!Object.hasOwn(value, key)) return false
|
|
247
|
+
}
|
|
248
|
+
for (const [key, propertySchema] of Object.entries(schema.properties ?? {})) {
|
|
249
|
+
if (Object.hasOwn(value, key) && !matchesValueSchema(propertySchema, value[key])) {
|
|
250
|
+
return false
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return true
|
|
254
|
+
}
|
|
255
|
+
default:
|
|
256
|
+
return false
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function registryEntry(predicate) {
|
|
261
|
+
return typeof predicate === 'string' && Object.hasOwn(PREDICATES, predicate)
|
|
262
|
+
? PREDICATES[predicate]
|
|
263
|
+
: null
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function nonEmptyString(value) {
|
|
267
|
+
return typeof value === 'string' && value.trim() !== ''
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function isRecord(value) {
|
|
271
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// A `sourceKind:'llm'` assertion is not a free-form claim: it must reproduce the
|
|
275
|
+
// engine's exact-quote provenance or it is refused before archiving. Evidence-less,
|
|
276
|
+
// unverified, observed or non-project LLM facts are therefore unrepresentable, not
|
|
277
|
+
// merely non-injectable.
|
|
278
|
+
function validateLlmAssertion(assertion, errors) {
|
|
279
|
+
if (!LLM_PREDICATES.has(assertion.predicate)) errors.push('llm-predicate-not-allowed')
|
|
280
|
+
if (assertion.sourceAuthority !== 'user-evidence') {
|
|
281
|
+
errors.push('llm-authority-not-user-evidence')
|
|
282
|
+
}
|
|
283
|
+
if (assertion.verification !== 'derived') errors.push('llm-verification-not-derived')
|
|
284
|
+
|
|
285
|
+
const evidence = assertion.evidence
|
|
286
|
+
if (!isRecord(evidence)) {
|
|
287
|
+
errors.push('llm-evidence-missing')
|
|
288
|
+
return
|
|
289
|
+
}
|
|
290
|
+
for (const key of Object.keys(evidence)) {
|
|
291
|
+
if (!LLM_EVIDENCE_FIELDS.has(key)) errors.push(`llm-evidence-unknown:${key}`)
|
|
292
|
+
}
|
|
293
|
+
for (const field of ['proposalId', 'eventKey', 'quote', 'model', 'modelDigest', 'schemaVersion']) {
|
|
294
|
+
if (!nonEmptyString(evidence[field])) errors.push(`llm-evidence-invalid:${field}`)
|
|
295
|
+
}
|
|
296
|
+
if (!Number.isInteger(evidence.start) || evidence.start < 0) {
|
|
297
|
+
errors.push('llm-evidence-invalid:start')
|
|
298
|
+
}
|
|
299
|
+
if (!Number.isInteger(evidence.end)) errors.push('llm-evidence-invalid:end')
|
|
300
|
+
if (Number.isInteger(evidence.start) && Number.isInteger(evidence.end)) {
|
|
301
|
+
if (evidence.end <= evidence.start) errors.push('llm-evidence-invalid:span')
|
|
302
|
+
else if (nonEmptyString(evidence.quote) &&
|
|
303
|
+
evidence.end - evidence.start !== evidence.quote.length) {
|
|
304
|
+
errors.push('llm-evidence-span-mismatch')
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
// A retraction carries no value; its quote is only the retraction evidence. Every
|
|
308
|
+
// value-bearing LLM fact must quote exactly the text it asserts.
|
|
309
|
+
if (assertion.op !== 'retract' &&
|
|
310
|
+
(!isRecord(assertion.value) || assertion.value.text !== evidence.quote)) {
|
|
311
|
+
errors.push('llm-evidence-quote-mismatch')
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export function validateAssertionInput(assertion) {
|
|
316
|
+
const errors = []
|
|
317
|
+
if (!assertion || typeof assertion !== 'object' || Array.isArray(assertion)) {
|
|
318
|
+
return { valid: false, errors: ['not-an-object'] }
|
|
319
|
+
}
|
|
320
|
+
for (const field of ['assertionId', 'scope', 'subjectEntityId']) {
|
|
321
|
+
if (!nonEmptyString(assertion[field])) errors.push(`missing-field:${field}`)
|
|
322
|
+
}
|
|
323
|
+
if (!nonEmptyString(assertion.sid)) errors.push('missing-field:sid')
|
|
324
|
+
if (!Number.isInteger(assertion.seq)) errors.push('invalid-field:seq')
|
|
325
|
+
if (!Number.isFinite(assertion.validAt)) errors.push('invalid-field:validAt')
|
|
326
|
+
if (!Number.isFinite(assertion.recordedAt)) errors.push('invalid-field:recordedAt')
|
|
327
|
+
if (assertion.qualifiers != null &&
|
|
328
|
+
(typeof assertion.qualifiers !== 'object' || Array.isArray(assertion.qualifiers))) {
|
|
329
|
+
errors.push('invalid-field:qualifiers')
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const contract = registryEntry(assertion.predicate)
|
|
333
|
+
if (!contract) errors.push('unknown-predicate')
|
|
334
|
+
if (!ASSERTION_OPERATIONS.has(assertion.op)) errors.push('unsupported-operation')
|
|
335
|
+
if (!VERIFICATION_VALUES.has(assertion.verification)) errors.push('invalid-verification')
|
|
336
|
+
if (contract && !contract.allowedSources.includes(assertion.sourceKind)) {
|
|
337
|
+
errors.push('source-not-allowed')
|
|
338
|
+
}
|
|
339
|
+
if (assertion.op === 'assert' && assertion.targetAssertionId != null) {
|
|
340
|
+
errors.push('unexpected-target-assertion')
|
|
341
|
+
}
|
|
342
|
+
if ((assertion.op === 'correct' || assertion.op === 'retract') &&
|
|
343
|
+
!nonEmptyString(assertion.targetAssertionId)) {
|
|
344
|
+
errors.push('missing-target-assertion')
|
|
345
|
+
}
|
|
346
|
+
if (contract && assertion.op !== 'retract' &&
|
|
347
|
+
!matchesValueSchema(contract.valueSchema, assertion.value)) {
|
|
348
|
+
errors.push('value-schema-invalid')
|
|
349
|
+
}
|
|
350
|
+
if (assertion.sourceKind === 'llm') validateLlmAssertion(assertion, errors)
|
|
351
|
+
return { valid: errors.length === 0, errors }
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export const PREDICATES = {
|
|
355
|
+
'file.exists': {
|
|
356
|
+
...fileContract,
|
|
357
|
+
valueSchema: { type: 'boolean' },
|
|
358
|
+
// Auto-injection renders only positive existence. `exists=false` remains a
|
|
359
|
+
// legitimate current fact for explicit audit, but absence markers are not
|
|
360
|
+
// injected into the model context: a renderer returning null contributes no line.
|
|
361
|
+
renderer: (fact) => (fact.value === true ? `文件路径为 ${fact.subjectDisplay},当前存在` : null),
|
|
362
|
+
},
|
|
363
|
+
'file.size': {
|
|
364
|
+
...fileContract,
|
|
365
|
+
valueSchema: { type: 'number', minimum: 0 },
|
|
366
|
+
renderer: (fact) => `文件 ${fact.subjectDisplay} 的大小为 ${fact.value} 字节`,
|
|
367
|
+
},
|
|
368
|
+
'file.mtime': {
|
|
369
|
+
...fileContract,
|
|
370
|
+
valueSchema: { type: 'number', minimum: 0 },
|
|
371
|
+
renderer: (fact) => `文件 ${fact.subjectDisplay} 的修改时间为 ${fact.value}`,
|
|
372
|
+
},
|
|
373
|
+
'file.sha256': {
|
|
374
|
+
...fileContract,
|
|
375
|
+
valueSchema: { type: 'string', pattern: '^[0-9a-f]{64}$' },
|
|
376
|
+
renderer: (fact) => `文件 ${fact.subjectDisplay} 的 SHA-256 为 ${fact.value}`,
|
|
377
|
+
},
|
|
378
|
+
'file.lines': {
|
|
379
|
+
...fileContract,
|
|
380
|
+
injectable: false,
|
|
381
|
+
invalidationRule: 'disabled-pending-encoding-and-newline-contract',
|
|
382
|
+
valueSchema: { type: 'number', minimum: 0 },
|
|
383
|
+
renderer: (fact) => `文件 ${fact.subjectDisplay} 的行数为 ${fact.value}`,
|
|
384
|
+
},
|
|
385
|
+
'project.decision': {
|
|
386
|
+
...projectContract,
|
|
387
|
+
renderer: (fact) => `项目决策:${fact.value.text}`,
|
|
388
|
+
},
|
|
389
|
+
'project.constraint': {
|
|
390
|
+
...projectContract,
|
|
391
|
+
renderer: (fact) => `项目约束:${fact.value.text}`,
|
|
392
|
+
},
|
|
393
|
+
'project.preference': {
|
|
394
|
+
...projectContract,
|
|
395
|
+
renderer: (fact) => `项目偏好:${fact.value.text}`,
|
|
396
|
+
},
|
|
397
|
+
'synthetic.set': {
|
|
398
|
+
allowedSources: [],
|
|
399
|
+
cardinality: 'multi',
|
|
400
|
+
mutable: true,
|
|
401
|
+
entityBound: false,
|
|
402
|
+
invalidationRule: 'test-only',
|
|
403
|
+
conflictRule: 'test-only',
|
|
404
|
+
injectable: false,
|
|
405
|
+
valueSchema: { type: 'object' },
|
|
406
|
+
renderer: (fact) => `测试集合元素:${canonicalJson(fact.value)}`,
|
|
407
|
+
},
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
export function factKey(fact) {
|
|
411
|
+
const predicate = registryEntry(fact.predicate)
|
|
412
|
+
if (!predicate) throw new TypeError(`unknown predicate: ${fact.predicate}`)
|
|
413
|
+
|
|
414
|
+
const qualifiers = fact.qualifiers === undefined ? null : fact.qualifiers
|
|
415
|
+
const parts = [fact.scope, fact.subjectEntityId, fact.generation, fact.predicate, canonicalJson(qualifiers)]
|
|
416
|
+
if (predicate.cardinality === 'multi') {
|
|
417
|
+
// A multi fact must carry its rule slot. The reducer derives it from the value
|
|
418
|
+
// for asserts and inherits it from the target for corrections/retractions; the
|
|
419
|
+
// value fallback keeps direct factKey() callers (tests, audits) well-defined.
|
|
420
|
+
const valueKey = fact.valueKey === undefined ? fact.value : fact.valueKey
|
|
421
|
+
parts.push(canonicalJson(valueKey))
|
|
422
|
+
}
|
|
423
|
+
return parts.join('/')
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
export function renderFacts(facts, budget) {
|
|
427
|
+
const lines = []
|
|
428
|
+
const selected = []
|
|
429
|
+
let text = ''
|
|
430
|
+
let overflowCount = 0
|
|
431
|
+
let suppressedCount = 0
|
|
432
|
+
let invalidCount = 0
|
|
433
|
+
|
|
434
|
+
for (const fact of facts) {
|
|
435
|
+
const predicate = registryEntry(fact.predicate)
|
|
436
|
+
// Unknown predicates and schema-invalid values are reported, never thrown: a
|
|
437
|
+
// corrupt archived fact must not take down the whole rendering path.
|
|
438
|
+
if (!predicate) {
|
|
439
|
+
invalidCount++
|
|
440
|
+
continue
|
|
441
|
+
}
|
|
442
|
+
if (!predicate.injectable || (fact.status !== undefined && fact.status !== 'active')) {
|
|
443
|
+
overflowCount++
|
|
444
|
+
continue
|
|
445
|
+
}
|
|
446
|
+
// Evidence-backed LLM facts (`derived`) may render after the project source
|
|
447
|
+
// contract was extended; an unverified or conflicted value still never does.
|
|
448
|
+
// Legacy snapshots remain suppressed regardless of their verification claim.
|
|
449
|
+
if (fact.conflict != null || fact.sourceKind === 'legacy' ||
|
|
450
|
+
(fact.verification !== undefined && !INJECTABLE_VERIFICATIONS.has(fact.verification))) {
|
|
451
|
+
suppressedCount++
|
|
452
|
+
continue
|
|
453
|
+
}
|
|
454
|
+
if (!matchesValueSchema(predicate.valueSchema, fact.value)) {
|
|
455
|
+
invalidCount++
|
|
456
|
+
continue
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
let line
|
|
460
|
+
try {
|
|
461
|
+
line = predicate.renderer(fact)
|
|
462
|
+
} catch {
|
|
463
|
+
invalidCount++
|
|
464
|
+
continue
|
|
465
|
+
}
|
|
466
|
+
// A null line means the predicate is a legitimate current fact that must not be
|
|
467
|
+
// auto-injected (for example `file.exists=false`). It is suppressed, not overflow.
|
|
468
|
+
if (line === null || line === undefined) {
|
|
469
|
+
suppressedCount++
|
|
470
|
+
continue
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const candidate = lines.length === 0 ? line : `${text}\n${line}`
|
|
474
|
+
if (candidate.length <= budget) {
|
|
475
|
+
lines.push(line)
|
|
476
|
+
selected.push(fact)
|
|
477
|
+
text = candidate
|
|
478
|
+
} else {
|
|
479
|
+
overflowCount++
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
return { text, selected, overflowCount, suppressedCount, invalidCount }
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const OBSERVATION_FIELDS = {
|
|
487
|
+
exists: 'file.exists',
|
|
488
|
+
size: 'file.size',
|
|
489
|
+
mtime: 'file.mtime',
|
|
490
|
+
sha256: 'file.sha256',
|
|
491
|
+
}
|
|
492
|
+
const WRITE_INVALIDATED_FIELDS = ['size', 'mtime', 'sha256']
|
|
493
|
+
|
|
494
|
+
function lexicalCompare(a, b) {
|
|
495
|
+
return a < b ? -1 : a > b ? 1 : 0
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function causalCompare(a, b) {
|
|
499
|
+
return (a[0] - b[0]) || lexicalCompare(a[1], b[1]) || (a[2] - b[2]) || lexicalCompare(a[3], b[3])
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function observationKind(observation) {
|
|
503
|
+
if (observation.op !== 'probe') return observation.op
|
|
504
|
+
return observation.value?.exists === false ? 'probe-missing' : 'probe-present'
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function observationSource(observation) {
|
|
508
|
+
return {
|
|
509
|
+
sourceKind: observation.source?.kind ?? observation.sourceKind ??
|
|
510
|
+
(observation.op === 'probe' ? 'probe' : 'observation'),
|
|
511
|
+
sourceAuthority: observation.source?.authority ?? observation.sourceAuthority ?? null,
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
export function projectLedger({ observations, assertions, reducerVersion }) {
|
|
516
|
+
const entityStates = new Map()
|
|
517
|
+
const observationPlacements = []
|
|
518
|
+
const assertionPlacements = []
|
|
519
|
+
const assertionPlacementById = new Map()
|
|
520
|
+
const assertionById = new Map()
|
|
521
|
+
const factVersions = []
|
|
522
|
+
const pendingFacts = []
|
|
523
|
+
const dead = []
|
|
524
|
+
|
|
525
|
+
// Archived input is untrusted: a single malformed assertion must be dead-lettered
|
|
526
|
+
// with a stable reason, never thrown, and never allowed to poison an otherwise valid
|
|
527
|
+
// scope. Structural validation runs before any placement or causal ordering.
|
|
528
|
+
const assertionInputs = Array.isArray(assertions) ? assertions : []
|
|
529
|
+
const validAssertions = []
|
|
530
|
+
for (let index = 0; index < assertionInputs.length; index++) {
|
|
531
|
+
const assertion = assertionInputs[index]
|
|
532
|
+
const validation = validateAssertionInput(assertion)
|
|
533
|
+
if (!validation.valid) {
|
|
534
|
+
dead.push({
|
|
535
|
+
assertionId: nonEmptyString(assertion?.assertionId)
|
|
536
|
+
? assertion.assertionId
|
|
537
|
+
: `assertion:index:${index}`,
|
|
538
|
+
reason: validation.errors[0],
|
|
539
|
+
errors: validation.errors,
|
|
540
|
+
})
|
|
541
|
+
continue
|
|
542
|
+
}
|
|
543
|
+
validAssertions.push(assertion)
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const terminalCounts = new Map()
|
|
547
|
+
for (const assertion of validAssertions) {
|
|
548
|
+
if (assertion.op !== 'correct' && assertion.op !== 'retract') continue
|
|
549
|
+
terminalCounts.set(assertion.targetAssertionId, (terminalCounts.get(assertion.targetAssertionId) ?? 0) + 1)
|
|
550
|
+
}
|
|
551
|
+
const duplicateTerminalIds = new Set(validAssertions.filter((assertion) =>
|
|
552
|
+
(assertion.op === 'correct' || assertion.op === 'retract') &&
|
|
553
|
+
terminalCounts.get(assertion.targetAssertionId) > 1).map((assertion) => assertion.assertionId))
|
|
554
|
+
for (const assertion of validAssertions) {
|
|
555
|
+
if (!duplicateTerminalIds.has(assertion.assertionId)) continue
|
|
556
|
+
dead.push({
|
|
557
|
+
assertionId: assertion.assertionId,
|
|
558
|
+
targetAssertionId: assertion.targetAssertionId,
|
|
559
|
+
reason: 'duplicate-terminal-operation',
|
|
560
|
+
})
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const changes = [
|
|
564
|
+
...observations.map((observation) => ({
|
|
565
|
+
kind: 'observation', value: observation,
|
|
566
|
+
causalOrder: [observation.validAt, observation.sid, observation.seq, `observation:${observation.eventKey}`],
|
|
567
|
+
})),
|
|
568
|
+
...validAssertions.map((assertion) => ({
|
|
569
|
+
kind: 'assertion', value: assertion,
|
|
570
|
+
causalOrder: [assertion.validAt, assertion.sid, assertion.seq, `assertion:${assertion.assertionId}`],
|
|
571
|
+
})),
|
|
572
|
+
].sort((a, b) => causalCompare(a.causalOrder, b.causalOrder))
|
|
573
|
+
|
|
574
|
+
function entityState(scope, entityId) {
|
|
575
|
+
const key = `${scope}\u0000${entityId}`
|
|
576
|
+
if (!entityStates.has(key)) {
|
|
577
|
+
entityStates.set(key, { scope, entityId, generations: [], lastValidAt: null, lastRecordedAt: null })
|
|
578
|
+
}
|
|
579
|
+
return entityStates.get(key)
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function currentGeneration(state) {
|
|
583
|
+
return state.generations[state.generations.length - 1] ?? null
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function openGeneration(state) {
|
|
587
|
+
return state.generations.find((generation) => generation.closedBy === null) ?? null
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function createGeneration(state, openedBy, validAt) {
|
|
591
|
+
const previous = currentGeneration(state)
|
|
592
|
+
if (previous) previous.replacedAt = validAt
|
|
593
|
+
const generation = {
|
|
594
|
+
number: (previous?.number ?? 0) + 1,
|
|
595
|
+
openedBy,
|
|
596
|
+
closedBy: null,
|
|
597
|
+
presence: true,
|
|
598
|
+
replacedAt: null,
|
|
599
|
+
}
|
|
600
|
+
state.generations.push(generation)
|
|
601
|
+
return generation
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function closeGeneration(state, causalOrder) {
|
|
605
|
+
let generation = openGeneration(state)
|
|
606
|
+
if (!generation && state.generations.length === 0) {
|
|
607
|
+
generation = createGeneration(state, causalOrder, causalOrder[0])
|
|
608
|
+
}
|
|
609
|
+
if (!generation) return currentGeneration(state)
|
|
610
|
+
generation.closedBy = causalOrder
|
|
611
|
+
generation.presence = false
|
|
612
|
+
return generation
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function placeObservation(observation, role, entityId, generation, reason = null) {
|
|
616
|
+
observationPlacements.push({
|
|
617
|
+
eventKey: observation.eventKey,
|
|
618
|
+
scope: observation.scope,
|
|
619
|
+
entityId,
|
|
620
|
+
role,
|
|
621
|
+
generation: generation?.number ?? null,
|
|
622
|
+
unplaced: generation === null,
|
|
623
|
+
reason,
|
|
624
|
+
causalOrder: [observation.validAt, observation.sid, observation.seq, `observation:${observation.eventKey}`],
|
|
625
|
+
})
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function placeAssertion(assertionId, observation, generation, reason = null) {
|
|
629
|
+
const placement = {
|
|
630
|
+
assertionId,
|
|
631
|
+
scope: observation.scope,
|
|
632
|
+
entityId: observation.subjectEntityId,
|
|
633
|
+
generation: generation?.number ?? null,
|
|
634
|
+
unplaced: generation === null,
|
|
635
|
+
reason,
|
|
636
|
+
causalOrder: [observation.validAt, observation.sid, observation.seq, assertionId],
|
|
637
|
+
}
|
|
638
|
+
assertionPlacements.push(placement)
|
|
639
|
+
assertionPlacementById.set(assertionId, placement)
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function deriveAssertion(observation, role, generation, predicate, op, value) {
|
|
643
|
+
const assertionId = `observation:${observation.eventKey}:${role}:${predicate}:${op}`
|
|
644
|
+
const subjectEntityId = role === 'target' ? observation.targetEntityId : observation.subjectEntityId
|
|
645
|
+
const source = observationSource(observation)
|
|
646
|
+
return {
|
|
647
|
+
fact: {
|
|
648
|
+
scope: observation.scope,
|
|
649
|
+
subjectEntityId,
|
|
650
|
+
subjectDisplay: subjectEntityId,
|
|
651
|
+
generation: generation.number,
|
|
652
|
+
predicate,
|
|
653
|
+
qualifiers: null,
|
|
654
|
+
value: op === 'invalidate' ? null : value,
|
|
655
|
+
assertionId,
|
|
656
|
+
evidence: { eventKey: observation.eventKey, rawRef: observation.rawRef ?? null },
|
|
657
|
+
causalOrder: [observation.validAt, observation.sid, observation.seq, assertionId],
|
|
658
|
+
validFrom: observation.validAt,
|
|
659
|
+
validTo: null,
|
|
660
|
+
recordedAt: observation.recordedAt,
|
|
661
|
+
retiredAt: null,
|
|
662
|
+
status: op === 'invalidate' ? 'invalidated' : 'active',
|
|
663
|
+
verification: 'observed',
|
|
664
|
+
conflict: null,
|
|
665
|
+
op,
|
|
666
|
+
sid: observation.sid,
|
|
667
|
+
seq: observation.seq,
|
|
668
|
+
reducerVersion,
|
|
669
|
+
...source,
|
|
670
|
+
},
|
|
671
|
+
generation,
|
|
672
|
+
subjectEntityId,
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function derivedForObservation(observation, role, generation) {
|
|
677
|
+
const kind = observationKind(observation)
|
|
678
|
+
const value = observation.value ?? {}
|
|
679
|
+
const derived = []
|
|
680
|
+
const assert = (predicate, factValue) => {
|
|
681
|
+
// Observation adapters should never mint a value that contradicts the registry
|
|
682
|
+
// schema. If archived input does, the measurement is unknown, not a poison fact.
|
|
683
|
+
const contract = registryEntry(predicate)
|
|
684
|
+
if (!contract || !matchesValueSchema(contract.valueSchema, factValue)) return
|
|
685
|
+
derived.push(deriveAssertion(observation, role, generation, predicate, 'assert', factValue))
|
|
686
|
+
}
|
|
687
|
+
const invalidate = (predicate) => {
|
|
688
|
+
derived.push(deriveAssertion(observation, role, generation, predicate, 'invalidate', null))
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
if (kind === 'scope_init') return derived
|
|
692
|
+
if (kind === 'create' || kind === 'write' || kind === 'probe-present') {
|
|
693
|
+
assert('file.exists', true)
|
|
694
|
+
if (kind === 'create' || kind === 'write') {
|
|
695
|
+
for (const field of WRITE_INVALIDATED_FIELDS) {
|
|
696
|
+
if (!Object.hasOwn(value, field)) invalidate(OBSERVATION_FIELDS[field])
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
for (const field of WRITE_INVALIDATED_FIELDS) {
|
|
700
|
+
if (Object.hasOwn(value, field)) assert(OBSERVATION_FIELDS[field], value[field])
|
|
701
|
+
}
|
|
702
|
+
} else if (kind === 'read' || kind === 'stat') {
|
|
703
|
+
for (const field of Object.keys(OBSERVATION_FIELDS)) {
|
|
704
|
+
if (Object.hasOwn(value, field)) assert(OBSERVATION_FIELDS[field], value[field])
|
|
705
|
+
}
|
|
706
|
+
} else if (kind === 'delete' || kind === 'probe-missing' || kind === 'rename-source') {
|
|
707
|
+
assert('file.exists', false)
|
|
708
|
+
for (const field of WRITE_INVALIDATED_FIELDS) invalidate(OBSERVATION_FIELDS[field])
|
|
709
|
+
} else if (kind === 'rename-target') {
|
|
710
|
+
assert('file.exists', true)
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
return derived.sort((a, b) => causalCompare(a.fact.causalOrder, b.fact.causalOrder))
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function applyDerived(observation, role, generation, kindOverride = null) {
|
|
717
|
+
const effectiveObservation = kindOverride === null ? observation : { ...observation, op: kindOverride }
|
|
718
|
+
const derived = derivedForObservation(effectiveObservation, role, generation)
|
|
719
|
+
for (const item of derived) {
|
|
720
|
+
pendingFacts.push(item)
|
|
721
|
+
assertionById.set(item.fact.assertionId, item.fact)
|
|
722
|
+
placeAssertion(item.fact.assertionId, {
|
|
723
|
+
...observation,
|
|
724
|
+
subjectEntityId: item.subjectEntityId,
|
|
725
|
+
}, generation)
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function processObservation(observation) {
|
|
730
|
+
const successful = observation.outcome === 'success' || observation.outcome === 'observed'
|
|
731
|
+
const state = entityState(observation.scope, observation.subjectEntityId)
|
|
732
|
+
if (!successful) {
|
|
733
|
+
const role = observation.op === 'rename' ? 'source' : 'subject'
|
|
734
|
+
placeObservation(observation, role, observation.subjectEntityId, null, `outcome-${observation.outcome}`)
|
|
735
|
+
if (observation.op === 'rename' && observation.targetEntityId != null) {
|
|
736
|
+
placeObservation(observation, 'target', observation.targetEntityId, null, `outcome-${observation.outcome}`)
|
|
737
|
+
}
|
|
738
|
+
return
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
const kind = observationKind(observation)
|
|
742
|
+
if (kind === 'scope_init') {
|
|
743
|
+
const generation = openGeneration(state) ?? createGeneration(state,
|
|
744
|
+
[observation.validAt, observation.sid, observation.seq, `observation:${observation.eventKey}`], observation.validAt)
|
|
745
|
+
state.lastValidAt = observation.validAt
|
|
746
|
+
state.lastRecordedAt = observation.recordedAt
|
|
747
|
+
placeObservation(observation, 'subject', observation.subjectEntityId, generation)
|
|
748
|
+
applyDerived(observation, 'subject', generation)
|
|
749
|
+
return
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// A success report that itself says the entity is absent is contradictory evidence.
|
|
753
|
+
// It degrades to unknown instead of minting exists=true or asserting stale metadata.
|
|
754
|
+
// delete/probe-missing are the legitimate absence transitions and bypass this guard.
|
|
755
|
+
if ((kind === 'create' || kind === 'write' || kind === 'probe-present' ||
|
|
756
|
+
kind === 'read' || kind === 'stat') && observation.value?.exists === false) {
|
|
757
|
+
placeObservation(observation, 'subject', observation.subjectEntityId, null,
|
|
758
|
+
'reported-exists-false')
|
|
759
|
+
return
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
if (kind === 'create' || kind === 'write' || kind === 'probe-present') {
|
|
763
|
+
const generation = openGeneration(state) ?? createGeneration(state,
|
|
764
|
+
[observation.validAt, observation.sid, observation.seq, `observation:${observation.eventKey}`], observation.validAt)
|
|
765
|
+
state.lastValidAt = observation.validAt
|
|
766
|
+
state.lastRecordedAt = observation.recordedAt
|
|
767
|
+
placeObservation(observation, 'subject', observation.subjectEntityId, generation)
|
|
768
|
+
applyDerived(observation, 'subject', generation)
|
|
769
|
+
return
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
if (kind === 'read' || kind === 'stat') {
|
|
773
|
+
const generation = openGeneration(state)
|
|
774
|
+
placeObservation(observation, 'subject', observation.subjectEntityId, generation,
|
|
775
|
+
generation ? null : 'no-matching-generation')
|
|
776
|
+
if (generation) {
|
|
777
|
+
state.lastValidAt = observation.validAt
|
|
778
|
+
state.lastRecordedAt = observation.recordedAt
|
|
779
|
+
applyDerived(observation, 'subject', generation)
|
|
780
|
+
}
|
|
781
|
+
return
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
if (kind === 'delete' || kind === 'probe-missing') {
|
|
785
|
+
const generation = closeGeneration(state,
|
|
786
|
+
[observation.validAt, observation.sid, observation.seq, `observation:${observation.eventKey}`])
|
|
787
|
+
state.lastValidAt = observation.validAt
|
|
788
|
+
state.lastRecordedAt = observation.recordedAt
|
|
789
|
+
placeObservation(observation, 'subject', observation.subjectEntityId, generation)
|
|
790
|
+
applyDerived(observation, 'subject', generation)
|
|
791
|
+
return
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
if (kind === 'rename') {
|
|
795
|
+
const targetState = entityState(observation.scope, observation.targetEntityId)
|
|
796
|
+
const sourceOpen = openGeneration(state)
|
|
797
|
+
const targetOpen = openGeneration(targetState)
|
|
798
|
+
if (observation.subjectEntityId === observation.targetEntityId && sourceOpen) {
|
|
799
|
+
placeObservation(observation, 'source', observation.subjectEntityId, sourceOpen)
|
|
800
|
+
placeObservation(observation, 'target', observation.targetEntityId, sourceOpen)
|
|
801
|
+
return
|
|
802
|
+
}
|
|
803
|
+
if (!sourceOpen || targetOpen) {
|
|
804
|
+
const reason = !sourceOpen ? 'source-not-open' : 'target-exists'
|
|
805
|
+
placeObservation(observation, 'source', observation.subjectEntityId, null, reason)
|
|
806
|
+
placeObservation(observation, 'target', observation.targetEntityId, null, reason)
|
|
807
|
+
return
|
|
808
|
+
}
|
|
809
|
+
const causalOrder = [observation.validAt, observation.sid, observation.seq, `observation:${observation.eventKey}`]
|
|
810
|
+
const sourceGeneration = closeGeneration(state, causalOrder)
|
|
811
|
+
const targetGeneration = createGeneration(targetState, causalOrder, observation.validAt)
|
|
812
|
+
state.lastValidAt = observation.validAt
|
|
813
|
+
state.lastRecordedAt = observation.recordedAt
|
|
814
|
+
targetState.lastValidAt = observation.validAt
|
|
815
|
+
targetState.lastRecordedAt = observation.recordedAt
|
|
816
|
+
placeObservation(observation, 'source', observation.subjectEntityId, sourceGeneration)
|
|
817
|
+
placeObservation(observation, 'target', observation.targetEntityId, targetGeneration)
|
|
818
|
+
applyDerived(observation, 'source', sourceGeneration, 'rename-source')
|
|
819
|
+
applyDerived(observation, 'target', targetGeneration, 'rename-target')
|
|
820
|
+
return
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
placeObservation(observation, 'subject', observation.subjectEntityId, null, 'unsupported-operation')
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// Resolve the rule slot for every multi assertion before any fact is built. An
|
|
827
|
+
// assert owns its value; a correction/retraction inherits the target's slot so it
|
|
828
|
+
// closes exactly that rule rather than opening a new one. The fixed-point loop
|
|
829
|
+
// also handles a correction whose target is itself a correction.
|
|
830
|
+
function multiValueKeysFor(assertions) {
|
|
831
|
+
const keys = new Map()
|
|
832
|
+
for (const assertion of assertions) {
|
|
833
|
+
const contract = registryEntry(assertion.predicate)
|
|
834
|
+
if (!contract || contract.cardinality !== 'multi') continue
|
|
835
|
+
if (assertion.valueKey !== undefined) {
|
|
836
|
+
keys.set(assertion.assertionId, assertion.valueKey)
|
|
837
|
+
} else if (assertion.op === 'assert' && assertion.value !== undefined && assertion.value !== null) {
|
|
838
|
+
keys.set(assertion.assertionId, assertion.value)
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
let changed = true
|
|
842
|
+
while (changed) {
|
|
843
|
+
changed = false
|
|
844
|
+
for (const assertion of assertions) {
|
|
845
|
+
if (keys.has(assertion.assertionId) ||
|
|
846
|
+
(assertion.op !== 'correct' && assertion.op !== 'retract')) continue
|
|
847
|
+
const targetKey = keys.get(assertion.targetAssertionId)
|
|
848
|
+
if (targetKey !== undefined) {
|
|
849
|
+
keys.set(assertion.assertionId, targetKey)
|
|
850
|
+
changed = true
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
return keys
|
|
855
|
+
}
|
|
856
|
+
const multiValueKeyById = multiValueKeysFor(validAssertions)
|
|
857
|
+
|
|
858
|
+
function processAssertion(assertion) {
|
|
859
|
+
let generation = null
|
|
860
|
+
let reason = null
|
|
861
|
+
if (assertion.op === 'correct' || assertion.op === 'retract') {
|
|
862
|
+
const target = assertionPlacementById.get(assertion.targetAssertionId)
|
|
863
|
+
if (target && !target.unplaced) generation = { number: target.generation }
|
|
864
|
+
else reason = 'target-unplaced'
|
|
865
|
+
} else {
|
|
866
|
+
const state = entityStates.get(`${assertion.scope}\u0000${assertion.subjectEntityId}`)
|
|
867
|
+
generation = state ? openGeneration(state) : null
|
|
868
|
+
if (!generation) reason = 'no-matching-generation'
|
|
869
|
+
}
|
|
870
|
+
const placement = {
|
|
871
|
+
assertionId: assertion.assertionId,
|
|
872
|
+
scope: assertion.scope,
|
|
873
|
+
entityId: assertion.subjectEntityId,
|
|
874
|
+
generation: generation?.number ?? null,
|
|
875
|
+
unplaced: generation === null,
|
|
876
|
+
reason,
|
|
877
|
+
causalOrder: [assertion.validAt, assertion.sid, assertion.seq, `assertion:${assertion.assertionId}`],
|
|
878
|
+
}
|
|
879
|
+
assertionPlacements.push(placement)
|
|
880
|
+
assertionPlacementById.set(assertion.assertionId, placement)
|
|
881
|
+
if (!duplicateTerminalIds.has(assertion.assertionId) &&
|
|
882
|
+
(generation || assertion.op === 'correct' || assertion.op === 'retract')) {
|
|
883
|
+
const contract = registryEntry(assertion.predicate)
|
|
884
|
+
const multiValueKey = contract?.cardinality === 'multi'
|
|
885
|
+
? multiValueKeyById.get(assertion.assertionId) ??
|
|
886
|
+
(assertion.op === 'assert' ? assertion.value : { unresolved: assertion.assertionId })
|
|
887
|
+
: undefined
|
|
888
|
+
const fact = {
|
|
889
|
+
scope: assertion.scope,
|
|
890
|
+
subjectEntityId: assertion.subjectEntityId,
|
|
891
|
+
subjectDisplay: assertion.subjectEntityId,
|
|
892
|
+
generation: generation?.number ?? null,
|
|
893
|
+
predicate: assertion.predicate,
|
|
894
|
+
qualifiers: assertion.qualifiers ?? null,
|
|
895
|
+
...(multiValueKey !== undefined ? { valueKey: multiValueKey } : {}),
|
|
896
|
+
value: assertion.op === 'retract' || assertion.op === 'invalidate' ? null : assertion.value,
|
|
897
|
+
assertionId: assertion.assertionId,
|
|
898
|
+
evidence: assertion.evidence ?? null,
|
|
899
|
+
causalOrder: [assertion.validAt, assertion.sid, assertion.seq, `assertion:${assertion.assertionId}`],
|
|
900
|
+
validFrom: assertion.validAt,
|
|
901
|
+
validTo: null,
|
|
902
|
+
recordedAt: assertion.recordedAt,
|
|
903
|
+
retiredAt: null,
|
|
904
|
+
status: assertion.op === 'invalidate' ? 'invalidated' : 'active',
|
|
905
|
+
verification: assertion.verification,
|
|
906
|
+
conflict: null,
|
|
907
|
+
op: assertion.op,
|
|
908
|
+
targetAssertionId: assertion.targetAssertionId ?? null,
|
|
909
|
+
sid: assertion.sid,
|
|
910
|
+
seq: assertion.seq,
|
|
911
|
+
reducerVersion,
|
|
912
|
+
sourceKind: assertion.sourceKind,
|
|
913
|
+
sourceAuthority: assertion.sourceAuthority ?? null,
|
|
914
|
+
}
|
|
915
|
+
pendingFacts.push({ fact, generation, subjectEntityId: assertion.subjectEntityId })
|
|
916
|
+
assertionById.set(assertion.assertionId, fact)
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
const deferredAssertions = []
|
|
921
|
+
for (const change of changes) {
|
|
922
|
+
if (change.kind === 'observation') processObservation(change.value)
|
|
923
|
+
else if ((change.value.op === 'correct' || change.value.op === 'retract') &&
|
|
924
|
+
!assertionPlacementById.has(change.value.targetAssertionId)) {
|
|
925
|
+
deferredAssertions.push(change.value)
|
|
926
|
+
} else processAssertion(change.value)
|
|
927
|
+
}
|
|
928
|
+
while (deferredAssertions.length) {
|
|
929
|
+
const waiting = deferredAssertions.splice(0)
|
|
930
|
+
let placedTarget = false
|
|
931
|
+
for (const assertion of waiting) {
|
|
932
|
+
if (assertionPlacementById.has(assertion.targetAssertionId)) {
|
|
933
|
+
processAssertion(assertion)
|
|
934
|
+
placedTarget = true
|
|
935
|
+
} else deferredAssertions.push(assertion)
|
|
936
|
+
}
|
|
937
|
+
if (!placedTarget) {
|
|
938
|
+
for (const assertion of deferredAssertions.splice(0)) processAssertion(assertion)
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
function generationFor(fact) {
|
|
943
|
+
const state = entityStates.get(`${fact.scope}\u0000${fact.subjectEntityId}`)
|
|
944
|
+
return state?.generations.find((generation) => generation.number === fact.generation) ?? null
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
function sameRowValue(a, b) {
|
|
948
|
+
return canonicalJson(a.value) === canonicalJson(b.value) &&
|
|
949
|
+
canonicalJson(a.conflict) === canonicalJson(b.conflict)
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
function systemRowKey(fact) {
|
|
953
|
+
return `${fact.factKey}\u0000${fact.assertionId}\u0000${fact.validFrom}`
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
// Per-fact key cache: canonicalizing qualifiers once per fact removes the repeated
|
|
957
|
+
// full-row canonicalization that dominated the old whole-prefix snapshot loop.
|
|
958
|
+
const factKeyCache = new WeakMap()
|
|
959
|
+
function cachedFactKey(fact) {
|
|
960
|
+
let key = factKeyCache.get(fact)
|
|
961
|
+
if (key === undefined) {
|
|
962
|
+
key = factKey(fact)
|
|
963
|
+
factKeyCache.set(fact, key)
|
|
964
|
+
}
|
|
965
|
+
return key
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
// System-version rows are immutable except for retiredAt (excluded from the
|
|
969
|
+
// signature), so their canonical signature is safe to cache across events.
|
|
970
|
+
const systemSignatureCache = new WeakMap()
|
|
971
|
+
function rowSignature(row) {
|
|
972
|
+
return canonicalJson({ ...row, recordedAt: null, retiredAt: null })
|
|
973
|
+
}
|
|
974
|
+
function cachedRowSignature(row) {
|
|
975
|
+
let signature = systemSignatureCache.get(row)
|
|
976
|
+
if (signature === undefined) {
|
|
977
|
+
signature = rowSignature(row)
|
|
978
|
+
systemSignatureCache.set(row, signature)
|
|
979
|
+
}
|
|
980
|
+
return signature
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
function removeAliveClaim(group, assertionId) {
|
|
984
|
+
const index = group.alive.findIndex((fact) => fact.assertionId === assertionId)
|
|
985
|
+
if (index !== -1) group.alive.splice(index, 1)
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
function applyClaimState(group, fact) {
|
|
989
|
+
if (fact.op === 'invalidate') {
|
|
990
|
+
group.latestInvalidation = fact.causalOrder
|
|
991
|
+
group.alive.length = 0
|
|
992
|
+
return
|
|
993
|
+
}
|
|
994
|
+
if (fact.op === 'correct' || fact.op === 'retract') {
|
|
995
|
+
group.terminated.add(fact.targetAssertionId)
|
|
996
|
+
removeAliveClaim(group, fact.targetAssertionId)
|
|
997
|
+
}
|
|
998
|
+
if ((fact.op === 'assert' || fact.op === 'correct') &&
|
|
999
|
+
!group.terminated.has(fact.assertionId)) {
|
|
1000
|
+
group.alive.push(fact)
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
function rebuildClaimState(group) {
|
|
1005
|
+
group.alive = []
|
|
1006
|
+
group.terminated = new Set()
|
|
1007
|
+
group.latestInvalidation = null
|
|
1008
|
+
for (const fact of group.items) applyClaimState(group, fact)
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
function claimsForAlive(group, contract) {
|
|
1012
|
+
if (contract.conflictRule === 'causal-order') {
|
|
1013
|
+
const last = group.alive[group.alive.length - 1]
|
|
1014
|
+
return last ? [last] : []
|
|
1015
|
+
}
|
|
1016
|
+
if (contract.conflictRule === 'equal-authority-conflict' && group.alive.length) {
|
|
1017
|
+
return conflictClaimsForAlive(group.alive, contract)
|
|
1018
|
+
}
|
|
1019
|
+
return group.alive.slice()
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
// Authority-aware conflict grouping for semantic predicates. Only claims at the
|
|
1023
|
+
// highest static authority present can become current. A lower-authority claim is
|
|
1024
|
+
// carried as `shadowed`, rendered as conflicted for audit, and can never force the
|
|
1025
|
+
// higher fact into conflict or duplicate its text. Among equal-authority claims the
|
|
1026
|
+
// original rule still applies: distinct values conflict instead of "newest wins".
|
|
1027
|
+
function conflictClaimsForAlive(alive, contract) {
|
|
1028
|
+
const order = Array.isArray(contract.sourceAuthorityOrder)
|
|
1029
|
+
? contract.sourceAuthorityOrder
|
|
1030
|
+
: null
|
|
1031
|
+
const latestByValue = (claims) => {
|
|
1032
|
+
const latest = new Map()
|
|
1033
|
+
for (const fact of claims) latest.set(canonicalJson(fact.value), fact)
|
|
1034
|
+
return [...latest.values()]
|
|
1035
|
+
}
|
|
1036
|
+
if (!order || order.length === 0) return latestByValue(alive)
|
|
1037
|
+
|
|
1038
|
+
const rankOf = (fact) => {
|
|
1039
|
+
const index = order.indexOf(fact.sourceKind)
|
|
1040
|
+
return index === -1 ? order.length : index
|
|
1041
|
+
}
|
|
1042
|
+
let best = Infinity
|
|
1043
|
+
for (const fact of alive) best = Math.min(best, rankOf(fact))
|
|
1044
|
+
const top = alive.filter((fact) => rankOf(fact) === best)
|
|
1045
|
+
const lower = alive.filter((fact) => rankOf(fact) > best)
|
|
1046
|
+
const topClaims = latestByValue(top)
|
|
1047
|
+
const higherAuthorityIds = topClaims.map((fact) => fact.assertionId).sort(lexicalCompare)
|
|
1048
|
+
const claims = [...topClaims]
|
|
1049
|
+
for (const fact of lower) {
|
|
1050
|
+
claims.push({ ...fact, shadowed: true, higherAuthorityIds })
|
|
1051
|
+
}
|
|
1052
|
+
return claims
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
function clampRow(row, nextValidAt) {
|
|
1056
|
+
row.validTo = nextValidAt
|
|
1057
|
+
const generation = generationFor(row)
|
|
1058
|
+
const closedAt = generation?.closedBy?.[0] ?? null
|
|
1059
|
+
if (generation?.replacedAt !== null && generation?.replacedAt !== undefined &&
|
|
1060
|
+
(row.validTo === null || generation.replacedAt < row.validTo)) {
|
|
1061
|
+
row.validTo = generation.replacedAt
|
|
1062
|
+
}
|
|
1063
|
+
if (registryEntry(row.predicate)?.entityBound && closedAt !== null && row.validFrom < closedAt &&
|
|
1064
|
+
(row.validTo === null || closedAt < row.validTo)) {
|
|
1065
|
+
row.validTo = closedAt
|
|
1066
|
+
}
|
|
1067
|
+
return row
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
function rowsForCurrent(key, claims, current) {
|
|
1071
|
+
const primary = claims.filter((fact) => fact.shadowed !== true)
|
|
1072
|
+
const conflicted = primary.length > 1
|
|
1073
|
+
const conflictIds = conflicted
|
|
1074
|
+
? primary.map((fact) => fact.assertionId).sort(lexicalCompare)
|
|
1075
|
+
: null
|
|
1076
|
+
const nextRows = claims.map((fact) => {
|
|
1077
|
+
const { shadowed, higherAuthorityIds, ...base } = fact
|
|
1078
|
+
if (shadowed === true) {
|
|
1079
|
+
// A lower-authority claim is preserved for audit but is never active and
|
|
1080
|
+
// never renders. Its conflict record points at the authority that shadows it.
|
|
1081
|
+
const assertionIds = [...new Set([
|
|
1082
|
+
...(Array.isArray(higherAuthorityIds) ? higherAuthorityIds : []),
|
|
1083
|
+
fact.assertionId,
|
|
1084
|
+
])].sort(lexicalCompare)
|
|
1085
|
+
return {
|
|
1086
|
+
...base,
|
|
1087
|
+
factKey: key,
|
|
1088
|
+
validFrom: current.validFrom,
|
|
1089
|
+
validTo: null,
|
|
1090
|
+
status: 'conflicted',
|
|
1091
|
+
conflict: { assertionIds, reason: 'lower-authority-shadowed' },
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
return {
|
|
1095
|
+
...base,
|
|
1096
|
+
factKey: key,
|
|
1097
|
+
validFrom: current.validFrom,
|
|
1098
|
+
validTo: null,
|
|
1099
|
+
status: conflicted ? 'conflicted' : 'active',
|
|
1100
|
+
conflict: conflicted
|
|
1101
|
+
? { assertionIds: conflictIds, reason: 'equal-authority-overlap' }
|
|
1102
|
+
: null,
|
|
1103
|
+
}
|
|
1104
|
+
})
|
|
1105
|
+
if (current.op === 'invalidate' && nextRows.length === 0) {
|
|
1106
|
+
nextRows.push({ ...current, factKey: key, validFrom: current.validFrom, validTo: null })
|
|
1107
|
+
}
|
|
1108
|
+
return nextRows
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
function mergeOrPush(rows, row) {
|
|
1112
|
+
const previous = rows[rows.length - 1]
|
|
1113
|
+
if (previous && previous.validTo === row.validFrom && previous.assertionId === row.assertionId &&
|
|
1114
|
+
previous.factKey === row.factKey && previous.status === row.status &&
|
|
1115
|
+
sameRowValue(previous, row)) {
|
|
1116
|
+
previous.validTo = row.validTo
|
|
1117
|
+
} else {
|
|
1118
|
+
rows.push(row)
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
// Bounded fallback used when a late fact lands in the middle of a group's causal
|
|
1123
|
+
// order. It mirrors the original whole-prefix semantics for that group only.
|
|
1124
|
+
function computeGroupRows(key, items) {
|
|
1125
|
+
const rows = []
|
|
1126
|
+
for (let index = 0; index < items.length; index++) {
|
|
1127
|
+
const terminated = new Set()
|
|
1128
|
+
let latestInvalidation = null
|
|
1129
|
+
for (let prior = 0; prior <= index; prior++) {
|
|
1130
|
+
const fact = items[prior]
|
|
1131
|
+
if (fact.op === 'correct' || fact.op === 'retract') terminated.add(fact.targetAssertionId)
|
|
1132
|
+
if (fact.op === 'invalidate') latestInvalidation = fact.causalOrder
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
let claims = items.slice(0, index + 1).filter((fact) =>
|
|
1136
|
+
(fact.op === 'assert' || fact.op === 'correct') && !terminated.has(fact.assertionId) &&
|
|
1137
|
+
(!latestInvalidation || causalCompare(fact.causalOrder, latestInvalidation) > 0))
|
|
1138
|
+
const contract = registryEntry(items[index].predicate)
|
|
1139
|
+
if (!contract) continue
|
|
1140
|
+
if (contract.conflictRule === 'causal-order') {
|
|
1141
|
+
claims = claims.length ? [claims[claims.length - 1]] : []
|
|
1142
|
+
} else if (contract.conflictRule === 'equal-authority-conflict' && claims.length) {
|
|
1143
|
+
claims = conflictClaimsForAlive(claims, contract)
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
const current = items[index]
|
|
1147
|
+
const nextRows = rowsForCurrent(key, claims, current)
|
|
1148
|
+
const nextValidAt = items[index + 1]?.validFrom ?? null
|
|
1149
|
+
for (const row of nextRows) mergeOrPush(rows, clampRow(row, nextValidAt))
|
|
1150
|
+
}
|
|
1151
|
+
for (const row of rows) {
|
|
1152
|
+
if (row.status === 'active' && row.validTo !== null) row.status = 'superseded'
|
|
1153
|
+
}
|
|
1154
|
+
return rows
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
// Monotonic append fast path: only the current group's final interval is closed,
|
|
1158
|
+
// new claims are derived from maintained alive-claim state, and one interval is
|
|
1159
|
+
// emitted. Total work for a group becomes linear instead of quadratic.
|
|
1160
|
+
function appendGroupIncremental(key, group, fact) {
|
|
1161
|
+
for (const row of group.rows) {
|
|
1162
|
+
if (row.validTo === null) clampRow(row, fact.validFrom)
|
|
1163
|
+
}
|
|
1164
|
+
applyClaimState(group, fact)
|
|
1165
|
+
const claims = claimsForAlive(group, registryEntry(fact.predicate))
|
|
1166
|
+
for (const row of rowsForCurrent(key, claims, fact)) {
|
|
1167
|
+
mergeOrPush(group.rows, clampRow(row, null))
|
|
1168
|
+
}
|
|
1169
|
+
for (const row of group.rows) {
|
|
1170
|
+
if (row.status === 'active' && row.validTo !== null) row.status = 'superseded'
|
|
1171
|
+
}
|
|
1172
|
+
return group.rows
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
function updateGroupRows(key, group, fact) {
|
|
1176
|
+
const last = group.items[group.items.length - 1]
|
|
1177
|
+
if (!last || causalCompare(last.causalOrder, fact.causalOrder) <= 0) {
|
|
1178
|
+
group.items.push(fact)
|
|
1179
|
+
return appendGroupIncremental(key, group, fact)
|
|
1180
|
+
}
|
|
1181
|
+
let low = 0
|
|
1182
|
+
let high = group.items.length
|
|
1183
|
+
while (low < high) {
|
|
1184
|
+
const middle = (low + high) >> 1
|
|
1185
|
+
if (causalCompare(group.items[middle].causalOrder, fact.causalOrder) <= 0) low = middle + 1
|
|
1186
|
+
else high = middle
|
|
1187
|
+
}
|
|
1188
|
+
group.items.splice(low, 0, fact)
|
|
1189
|
+
group.rows = computeGroupRows(key, group.items)
|
|
1190
|
+
rebuildClaimState(group)
|
|
1191
|
+
return group.rows
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
const systemFacts = pendingFacts.slice().sort((a, b) =>
|
|
1195
|
+
(a.fact.recordedAt - b.fact.recordedAt) || lexicalCompare(a.fact.sourceKind, b.fact.sourceKind) ||
|
|
1196
|
+
lexicalCompare(a.fact.assertionId, b.fact.assertionId))
|
|
1197
|
+
const applied = []
|
|
1198
|
+
const appliedById = new Map()
|
|
1199
|
+
const terminatedTargets = new Set()
|
|
1200
|
+
const groupSnapshots = new Map()
|
|
1201
|
+
const systemRowsByGroup = new Map()
|
|
1202
|
+
|
|
1203
|
+
for (const item of systemFacts) {
|
|
1204
|
+
const fact = item.fact
|
|
1205
|
+
if (fact.op === 'correct' || fact.op === 'retract') {
|
|
1206
|
+
const target = appliedById.get(fact.targetAssertionId)
|
|
1207
|
+
let reason = null
|
|
1208
|
+
if (!target) reason = 'target-unresolved'
|
|
1209
|
+
else if (terminatedTargets.has(target.assertionId)) reason = 'duplicate-terminal-operation'
|
|
1210
|
+
else if (target.scope !== fact.scope || target.subjectEntityId !== fact.subjectEntityId ||
|
|
1211
|
+
target.predicate !== fact.predicate ||
|
|
1212
|
+
canonicalJson(target.qualifiers) !== canonicalJson(fact.qualifiers)) reason = 'target-contract-mismatch'
|
|
1213
|
+
else if (fact.validFrom < target.validFrom) reason = 'terminal-valid-at-before-target'
|
|
1214
|
+
if (reason) {
|
|
1215
|
+
dead.push({ assertionId: fact.assertionId, targetAssertionId: fact.targetAssertionId, reason })
|
|
1216
|
+
continue
|
|
1217
|
+
}
|
|
1218
|
+
terminatedTargets.add(target.assertionId)
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
applied.push(item)
|
|
1222
|
+
appliedById.set(fact.assertionId, fact)
|
|
1223
|
+
|
|
1224
|
+
const key = cachedFactKey(fact)
|
|
1225
|
+
let group = groupSnapshots.get(key)
|
|
1226
|
+
if (!group) {
|
|
1227
|
+
group = {
|
|
1228
|
+
key, items: [], rows: [], alive: [], terminated: new Set(), latestInvalidation: null,
|
|
1229
|
+
}
|
|
1230
|
+
groupSnapshots.set(key, group)
|
|
1231
|
+
}
|
|
1232
|
+
const nextSnapshot = updateGroupRows(key, group, fact)
|
|
1233
|
+
const previousRows = systemRowsByGroup.get(key) ?? []
|
|
1234
|
+
const previousByKey = new Map(previousRows.map((row) => [systemRowKey(row), row]))
|
|
1235
|
+
const nextByKey = new Map(nextSnapshot.map((row) => [systemRowKey(row), row]))
|
|
1236
|
+
const nextSystemRows = []
|
|
1237
|
+
|
|
1238
|
+
for (const [rowKey, previous] of previousByKey) {
|
|
1239
|
+
const nextRow = nextByKey.get(rowKey)
|
|
1240
|
+
if (!nextRow || cachedRowSignature(previous) !== rowSignature(nextRow)) {
|
|
1241
|
+
previous.retiredAt = fact.recordedAt
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
for (const [rowKey, row] of nextByKey) {
|
|
1245
|
+
const previous = previousByKey.get(rowKey)
|
|
1246
|
+
if (previous && previous.retiredAt === null) {
|
|
1247
|
+
nextSystemRows.push(previous)
|
|
1248
|
+
} else {
|
|
1249
|
+
const version = { ...row, recordedAt: fact.recordedAt, retiredAt: null }
|
|
1250
|
+
systemSignatureCache.set(version, rowSignature(version))
|
|
1251
|
+
factVersions.push(version)
|
|
1252
|
+
nextSystemRows.push(version)
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
systemRowsByGroup.set(key, nextSystemRows)
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
const systemRows = [...systemRowsByGroup.values()].flat()
|
|
1259
|
+
|
|
1260
|
+
const entities = []
|
|
1261
|
+
const currentGenerationKeys = new Set()
|
|
1262
|
+
for (const state of entityStates.values()) {
|
|
1263
|
+
const generation = currentGeneration(state)
|
|
1264
|
+
if (!generation) continue
|
|
1265
|
+
entities.push({
|
|
1266
|
+
scope: state.scope,
|
|
1267
|
+
entityId: state.entityId,
|
|
1268
|
+
generation: generation.number,
|
|
1269
|
+
presence: generation.presence,
|
|
1270
|
+
openedBy: generation.openedBy,
|
|
1271
|
+
closedBy: generation.closedBy,
|
|
1272
|
+
lastValidAt: state.lastValidAt,
|
|
1273
|
+
lastRecordedAt: state.lastRecordedAt,
|
|
1274
|
+
})
|
|
1275
|
+
currentGenerationKeys.add(`${state.scope}\u0000${state.entityId}\u0000${generation.number}`)
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
const currentFacts = systemRows.filter((fact) =>
|
|
1279
|
+
currentGenerationKeys.has(`${fact.scope}\u0000${fact.subjectEntityId}\u0000${fact.generation}`) &&
|
|
1280
|
+
(fact.op === 'assert' || fact.op === 'correct') &&
|
|
1281
|
+
(fact.status === 'active' || fact.status === 'conflicted') &&
|
|
1282
|
+
fact.validTo === null && fact.retiredAt === null)
|
|
1283
|
+
|
|
1284
|
+
entities.sort((a, b) => lexicalCompare(a.scope, b.scope) || lexicalCompare(a.entityId, b.entityId))
|
|
1285
|
+
factVersions.sort((a, b) => causalCompare(a.causalOrder, b.causalOrder) ||
|
|
1286
|
+
(a.recordedAt - b.recordedAt) || lexicalCompare(a.factKey, b.factKey) || (a.validFrom - b.validFrom))
|
|
1287
|
+
currentFacts.sort((a, b) => lexicalCompare(a.factKey, b.factKey) || lexicalCompare(a.assertionId, b.assertionId))
|
|
1288
|
+
|
|
1289
|
+
return {
|
|
1290
|
+
reducerVersion,
|
|
1291
|
+
entities,
|
|
1292
|
+
observationPlacements,
|
|
1293
|
+
assertionPlacements,
|
|
1294
|
+
factVersions,
|
|
1295
|
+
currentFacts,
|
|
1296
|
+
dead,
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
export function factsAt(projection, { validAt, recordedAt }) {
|
|
1301
|
+
return projection.factVersions.filter((fact) =>
|
|
1302
|
+
fact.recordedAt <= recordedAt && (fact.retiredAt === null || recordedAt < fact.retiredAt) &&
|
|
1303
|
+
fact.validFrom <= validAt && (fact.validTo === null || validAt < fact.validTo) &&
|
|
1304
|
+
(fact.status === 'active' || fact.status === 'superseded' || fact.status === 'conflicted'))
|
|
1305
|
+
.sort((a, b) => lexicalCompare(a.factKey, b.factKey) || lexicalCompare(a.assertionId, b.assertionId))
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
export function projectionHash(projection) {
|
|
1309
|
+
const facts = projection.currentFacts
|
|
1310
|
+
.filter((fact) => (fact.status === 'active' || fact.status === 'conflicted') &&
|
|
1311
|
+
fact.validTo === null && fact.retiredAt === null)
|
|
1312
|
+
.map((fact) => ({
|
|
1313
|
+
fact_key: fact.factKey,
|
|
1314
|
+
subject_entity_id: fact.subjectEntityId,
|
|
1315
|
+
generation: fact.generation,
|
|
1316
|
+
predicate: fact.predicate,
|
|
1317
|
+
value_json: fact.value ?? null,
|
|
1318
|
+
qualifiers_json: fact.qualifiers ?? null,
|
|
1319
|
+
assertion_id: fact.assertionId,
|
|
1320
|
+
evidence_json: fact.evidence ?? null,
|
|
1321
|
+
causal_order: fact.causalOrder,
|
|
1322
|
+
valid_from: fact.validFrom,
|
|
1323
|
+
valid_to: fact.validTo ?? null,
|
|
1324
|
+
recorded_at: fact.recordedAt,
|
|
1325
|
+
status: fact.status,
|
|
1326
|
+
verification: fact.verification,
|
|
1327
|
+
conflict_json: fact.conflict ?? null,
|
|
1328
|
+
}))
|
|
1329
|
+
.sort((a, b) => lexicalCompare(a.fact_key, b.fact_key) || lexicalCompare(a.assertion_id, b.assertion_id))
|
|
1330
|
+
return sha256Canonical({ reducer_version: projection.reducerVersion, facts })
|
|
1331
|
+
}
|