@private.me/xcontinuity 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +123 -0
- package/LICENSE.md +26 -0
- package/MIGRATING.md +77 -0
- package/README.md +601 -0
- package/dist/adjudicator.d.ts +75 -0
- package/dist/adjudicator.js +184 -0
- package/dist/cascade.d.ts +157 -0
- package/dist/cascade.js +323 -0
- package/dist/chronicle.d.ts +76 -0
- package/dist/chronicle.js +173 -0
- package/dist/cjs/adjudicator.js +189 -0
- package/dist/cjs/cascade.js +328 -0
- package/dist/cjs/chronicle.js +178 -0
- package/dist/cjs/enforcement.js +108 -0
- package/dist/cjs/errors.js +72 -0
- package/dist/cjs/index.js +108 -0
- package/dist/cjs/memory-runtime.js +129 -0
- package/dist/cjs/memory-session.js +134 -0
- package/dist/cjs/mission.js +178 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/provenance.js +192 -0
- package/dist/cjs/ratification.js +322 -0
- package/dist/cjs/reverse-xorida.js +506 -0
- package/dist/cjs/session.js +273 -0
- package/dist/cjs/state-serializer.js +300 -0
- package/dist/cjs/store-memory.js +33 -0
- package/dist/cjs/trust.js +133 -0
- package/dist/cjs/types.js +59 -0
- package/dist/enforcement.d.ts +40 -0
- package/dist/enforcement.js +104 -0
- package/dist/errors.d.ts +25 -0
- package/dist/errors.js +68 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.js +43 -0
- package/dist/memory-runtime.d.ts +36 -0
- package/dist/memory-runtime.js +125 -0
- package/dist/memory-session.d.ts +38 -0
- package/dist/memory-session.js +97 -0
- package/dist/mission.d.ts +68 -0
- package/dist/mission.js +172 -0
- package/dist/provenance.d.ts +54 -0
- package/dist/provenance.js +182 -0
- package/dist/ratification.d.ts +113 -0
- package/dist/ratification.js +317 -0
- package/dist/reverse-xorida.d.ts +174 -0
- package/dist/reverse-xorida.js +490 -0
- package/dist/session.d.ts +102 -0
- package/dist/session.js +269 -0
- package/dist/state-serializer.d.ts +37 -0
- package/dist/state-serializer.js +294 -0
- package/dist/store-memory.d.ts +18 -0
- package/dist/store-memory.js +29 -0
- package/dist/trust.d.ts +76 -0
- package/dist/trust.js +121 -0
- package/dist/types.d.ts +320 -0
- package/dist/types.js +56 -0
- package/llms.txt +43 -0
- package/package.json +125 -0
- package/share1.dat +0 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @private.me/xcontinuity — Chronicle (State History)
|
|
3
|
+
*
|
|
4
|
+
* Append-only ordered state history with navigation.
|
|
5
|
+
* Secondary index (Map stateId->index) for O(1) lookup.
|
|
6
|
+
* Query with filters: sessionId, time range, tags, limit/offset pagination.
|
|
7
|
+
*/
|
|
8
|
+
import type { Result } from '@private.me/shared';
|
|
9
|
+
import type { ChronicleEntry, ChronicleQuery, StateValue } from './types.js';
|
|
10
|
+
import type { ContinuityError } from './errors.js';
|
|
11
|
+
/** Extended chronicle entry with contradiction and revision tracking. */
|
|
12
|
+
export interface ChronicleContradiction {
|
|
13
|
+
/** The stateId of the new (incoming) entry. */
|
|
14
|
+
readonly incomingStateId: string;
|
|
15
|
+
/** The stateId of the existing (contradicted) entry. */
|
|
16
|
+
readonly existingStateId: string;
|
|
17
|
+
/** The key on which the contradiction was detected. */
|
|
18
|
+
readonly key: string;
|
|
19
|
+
/** Timestamp when the contradiction was detected. */
|
|
20
|
+
readonly detectedAt: number;
|
|
21
|
+
}
|
|
22
|
+
export declare class Chronicle {
|
|
23
|
+
private readonly entries;
|
|
24
|
+
private readonly indexByStateId;
|
|
25
|
+
/** Track the latest stateId for each key (for contradiction detection). */
|
|
26
|
+
private readonly latestByKey;
|
|
27
|
+
/** Detected contradictions. */
|
|
28
|
+
private readonly contradictions;
|
|
29
|
+
/** Append a new entry to the chronicle. */
|
|
30
|
+
append(entry: ChronicleEntry): void;
|
|
31
|
+
/**
|
|
32
|
+
* Append an entry and check for contradictions against the latest
|
|
33
|
+
* value for the same key. Returns detected contradictions (if any).
|
|
34
|
+
*
|
|
35
|
+
* @param entry - The chronicle entry
|
|
36
|
+
* @param key - The state key this entry represents
|
|
37
|
+
* @param value - The new value for the key
|
|
38
|
+
* @param existingValue - The most recent known value for the key (if any)
|
|
39
|
+
* @returns Array of contradictions detected (empty if none)
|
|
40
|
+
*/
|
|
41
|
+
appendWithContradictionCheck(entry: ChronicleEntry, key: string, value: StateValue, existingValue?: StateValue): ChronicleContradiction[];
|
|
42
|
+
/** Get all detected contradictions. */
|
|
43
|
+
getContradictions(): readonly ChronicleContradiction[];
|
|
44
|
+
/** Get contradictions for a specific key. */
|
|
45
|
+
getContradictionsForKey(key: string): readonly ChronicleContradiction[];
|
|
46
|
+
/** Get the latest stateId recorded for a given key. */
|
|
47
|
+
getLatestForKey(key: string): string | undefined;
|
|
48
|
+
/** Get an entry by stateId. O(1) lookup. */
|
|
49
|
+
get(stateId: string): Result<ChronicleEntry, ContinuityError>;
|
|
50
|
+
/** Get the latest entry, or null if empty. */
|
|
51
|
+
latest(): ChronicleEntry | null;
|
|
52
|
+
/** Get entry at a specific sequence position. */
|
|
53
|
+
atSequence(sequence: number): Result<ChronicleEntry, ContinuityError>;
|
|
54
|
+
/** Get the parent entry (if parentStateId is set). */
|
|
55
|
+
parent(stateId: string): Result<ChronicleEntry, ContinuityError>;
|
|
56
|
+
/**
|
|
57
|
+
* Query chronicle entries with filters.
|
|
58
|
+
*
|
|
59
|
+
* @param query - Filter criteria (sessionId, time range, tags, limit/offset)
|
|
60
|
+
* @returns Matching entries ordered by timestamp
|
|
61
|
+
*/
|
|
62
|
+
query(query?: ChronicleQuery): ChronicleEntry[];
|
|
63
|
+
/** Total number of entries. */
|
|
64
|
+
get length(): number;
|
|
65
|
+
/** Get all entries (readonly copy). */
|
|
66
|
+
all(): readonly ChronicleEntry[];
|
|
67
|
+
/** Clear all entries. */
|
|
68
|
+
clear(): void;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Detect whether two values for the same key contradict each other.
|
|
72
|
+
*
|
|
73
|
+
* Contradiction = values are structurally different.
|
|
74
|
+
* Identical values are not contradictions (they're confirmations).
|
|
75
|
+
*/
|
|
76
|
+
export declare function detectContradiction(newValue: StateValue, existingValue: StateValue): boolean;
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @private.me/xcontinuity — Chronicle (State History)
|
|
3
|
+
*
|
|
4
|
+
* Append-only ordered state history with navigation.
|
|
5
|
+
* Secondary index (Map stateId->index) for O(1) lookup.
|
|
6
|
+
* Query with filters: sessionId, time range, tags, limit/offset pagination.
|
|
7
|
+
*/
|
|
8
|
+
import { ok, err } from '@private.me/shared';
|
|
9
|
+
import { continuityError } from './errors.js';
|
|
10
|
+
export class Chronicle {
|
|
11
|
+
entries = [];
|
|
12
|
+
indexByStateId = new Map();
|
|
13
|
+
/** Track the latest stateId for each key (for contradiction detection). */
|
|
14
|
+
latestByKey = new Map();
|
|
15
|
+
/** Detected contradictions. */
|
|
16
|
+
contradictions = [];
|
|
17
|
+
/** Append a new entry to the chronicle. */
|
|
18
|
+
append(entry) {
|
|
19
|
+
const idx = this.entries.length;
|
|
20
|
+
this.entries.push(entry);
|
|
21
|
+
this.indexByStateId.set(entry.stateId, idx);
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Append an entry and check for contradictions against the latest
|
|
25
|
+
* value for the same key. Returns detected contradictions (if any).
|
|
26
|
+
*
|
|
27
|
+
* @param entry - The chronicle entry
|
|
28
|
+
* @param key - The state key this entry represents
|
|
29
|
+
* @param value - The new value for the key
|
|
30
|
+
* @param existingValue - The most recent known value for the key (if any)
|
|
31
|
+
* @returns Array of contradictions detected (empty if none)
|
|
32
|
+
*/
|
|
33
|
+
appendWithContradictionCheck(entry, key, value, existingValue) {
|
|
34
|
+
const detected = [];
|
|
35
|
+
const previousStateId = this.latestByKey.get(key);
|
|
36
|
+
if (previousStateId !== undefined && existingValue !== undefined) {
|
|
37
|
+
if (detectContradiction(value, existingValue)) {
|
|
38
|
+
const contradiction = {
|
|
39
|
+
incomingStateId: entry.stateId,
|
|
40
|
+
existingStateId: previousStateId,
|
|
41
|
+
key,
|
|
42
|
+
detectedAt: Date.now(),
|
|
43
|
+
};
|
|
44
|
+
detected.push(contradiction);
|
|
45
|
+
this.contradictions.push(contradiction);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
this.latestByKey.set(key, entry.stateId);
|
|
49
|
+
this.append(entry);
|
|
50
|
+
return detected;
|
|
51
|
+
}
|
|
52
|
+
/** Get all detected contradictions. */
|
|
53
|
+
getContradictions() {
|
|
54
|
+
return this.contradictions.slice();
|
|
55
|
+
}
|
|
56
|
+
/** Get contradictions for a specific key. */
|
|
57
|
+
getContradictionsForKey(key) {
|
|
58
|
+
return this.contradictions.filter(c => c.key === key);
|
|
59
|
+
}
|
|
60
|
+
/** Get the latest stateId recorded for a given key. */
|
|
61
|
+
getLatestForKey(key) {
|
|
62
|
+
return this.latestByKey.get(key);
|
|
63
|
+
}
|
|
64
|
+
/** Get an entry by stateId. O(1) lookup. */
|
|
65
|
+
get(stateId) {
|
|
66
|
+
const idx = this.indexByStateId.get(stateId);
|
|
67
|
+
if (idx === undefined) {
|
|
68
|
+
return err(continuityError('ENTRY_NOT_FOUND', `Chronicle entry ${stateId} not found`));
|
|
69
|
+
}
|
|
70
|
+
return ok(this.entries[idx]);
|
|
71
|
+
}
|
|
72
|
+
/** Get the latest entry, or null if empty. */
|
|
73
|
+
latest() {
|
|
74
|
+
if (this.entries.length === 0)
|
|
75
|
+
return null;
|
|
76
|
+
return this.entries[this.entries.length - 1];
|
|
77
|
+
}
|
|
78
|
+
/** Get entry at a specific sequence position. */
|
|
79
|
+
atSequence(sequence) {
|
|
80
|
+
const entry = this.entries.find(e => e.sequence === sequence);
|
|
81
|
+
if (!entry) {
|
|
82
|
+
return err(continuityError('ENTRY_NOT_FOUND', `No entry with sequence ${sequence}`));
|
|
83
|
+
}
|
|
84
|
+
return ok(entry);
|
|
85
|
+
}
|
|
86
|
+
/** Get the parent entry (if parentStateId is set). */
|
|
87
|
+
parent(stateId) {
|
|
88
|
+
const entryResult = this.get(stateId);
|
|
89
|
+
if (!entryResult.ok)
|
|
90
|
+
return entryResult;
|
|
91
|
+
const entry = entryResult.value;
|
|
92
|
+
if (!entry.parentStateId) {
|
|
93
|
+
return err(continuityError('ENTRY_NOT_FOUND', `Entry ${stateId} has no parent`));
|
|
94
|
+
}
|
|
95
|
+
return this.get(entry.parentStateId);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Query chronicle entries with filters.
|
|
99
|
+
*
|
|
100
|
+
* @param query - Filter criteria (sessionId, time range, tags, limit/offset)
|
|
101
|
+
* @returns Matching entries ordered by timestamp
|
|
102
|
+
*/
|
|
103
|
+
query(query = {}) {
|
|
104
|
+
let results = this.entries.slice();
|
|
105
|
+
if (query.sessionId) {
|
|
106
|
+
results = results.filter(e => e.sessionId === query.sessionId);
|
|
107
|
+
}
|
|
108
|
+
if (query.after !== undefined) {
|
|
109
|
+
results = results.filter(e => e.timestamp >= query.after);
|
|
110
|
+
}
|
|
111
|
+
if (query.before !== undefined) {
|
|
112
|
+
results = results.filter(e => e.timestamp <= query.before);
|
|
113
|
+
}
|
|
114
|
+
if (query.tags && query.tags.length > 0) {
|
|
115
|
+
const tagSet = new Set(query.tags);
|
|
116
|
+
results = results.filter(e => e.tags.some(t => tagSet.has(t)));
|
|
117
|
+
}
|
|
118
|
+
// Pagination
|
|
119
|
+
const offset = query.offset ?? 0;
|
|
120
|
+
const limit = query.limit ?? results.length;
|
|
121
|
+
return results.slice(offset, offset + limit);
|
|
122
|
+
}
|
|
123
|
+
/** Total number of entries. */
|
|
124
|
+
get length() {
|
|
125
|
+
return this.entries.length;
|
|
126
|
+
}
|
|
127
|
+
/** Get all entries (readonly copy). */
|
|
128
|
+
all() {
|
|
129
|
+
return this.entries.slice();
|
|
130
|
+
}
|
|
131
|
+
/** Clear all entries. */
|
|
132
|
+
clear() {
|
|
133
|
+
this.entries.length = 0;
|
|
134
|
+
this.indexByStateId.clear();
|
|
135
|
+
this.latestByKey.clear();
|
|
136
|
+
this.contradictions.length = 0;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Detect whether two values for the same key contradict each other.
|
|
141
|
+
*
|
|
142
|
+
* Contradiction = values are structurally different.
|
|
143
|
+
* Identical values are not contradictions (they're confirmations).
|
|
144
|
+
*/
|
|
145
|
+
export function detectContradiction(newValue, existingValue) {
|
|
146
|
+
// Null checks
|
|
147
|
+
if (newValue === null && existingValue === null)
|
|
148
|
+
return false;
|
|
149
|
+
if (newValue === null || existingValue === null)
|
|
150
|
+
return true;
|
|
151
|
+
// Uint8Array comparison
|
|
152
|
+
if (newValue instanceof Uint8Array && existingValue instanceof Uint8Array) {
|
|
153
|
+
if (newValue.length !== existingValue.length)
|
|
154
|
+
return true;
|
|
155
|
+
for (let i = 0; i < newValue.length; i++) {
|
|
156
|
+
if (newValue[i] !== existingValue[i])
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
// Type mismatch
|
|
162
|
+
if (typeof newValue !== typeof existingValue)
|
|
163
|
+
return true;
|
|
164
|
+
if (newValue instanceof Uint8Array || existingValue instanceof Uint8Array)
|
|
165
|
+
return true;
|
|
166
|
+
// Object comparison (deterministic JSON)
|
|
167
|
+
if (typeof newValue === 'object' && typeof existingValue === 'object') {
|
|
168
|
+
return JSON.stringify(newValue, Object.keys(newValue).sort())
|
|
169
|
+
!== JSON.stringify(existingValue, Object.keys(existingValue).sort());
|
|
170
|
+
}
|
|
171
|
+
// Primitive comparison
|
|
172
|
+
return newValue !== existingValue;
|
|
173
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @private.me/xcontinuity — Adjudicator (Conflict Resolution)
|
|
4
|
+
*
|
|
5
|
+
* Resolves conflicts when multiple entries exist for the same key.
|
|
6
|
+
*
|
|
7
|
+
* Two implementations:
|
|
8
|
+
* PolicyAdjudicator — deterministic local resolution (default)
|
|
9
|
+
* ConsensusAdjudicator — multi-agent IXorIDA-backed resolution
|
|
10
|
+
*
|
|
11
|
+
* Deterministic tiebreaker (total ordering):
|
|
12
|
+
* 1. Highest trust tier
|
|
13
|
+
* 2. Newest timestamp
|
|
14
|
+
* 3. Lexicographically lowest author DID
|
|
15
|
+
*
|
|
16
|
+
* This guarantees two agents merging the same conflicting entries
|
|
17
|
+
* always pick the same winner — convergent merge without human input.
|
|
18
|
+
*/
|
|
19
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
20
|
+
exports.ConsensusAdjudicator = exports.PolicyAdjudicator = void 0;
|
|
21
|
+
const shared_1 = require("@private.me/shared");
|
|
22
|
+
const types_js_1 = require("./types.js");
|
|
23
|
+
const errors_js_1 = require("./errors.js");
|
|
24
|
+
const trust_js_1 = require("./trust.js");
|
|
25
|
+
/**
|
|
26
|
+
* Policy-based adjudicator (default).
|
|
27
|
+
*
|
|
28
|
+
* Deterministic resolution using total ordering:
|
|
29
|
+
* 1. Highest effective trust tier (with decay applied)
|
|
30
|
+
* 2. Newest timestamp (higher = more recent)
|
|
31
|
+
* 3. Lexicographically lowest author DID (deterministic tiebreaker)
|
|
32
|
+
*/
|
|
33
|
+
class PolicyAdjudicator {
|
|
34
|
+
resolve(key, candidates) {
|
|
35
|
+
if (candidates.length === 0) {
|
|
36
|
+
return (0, shared_1.err)((0, errors_js_1.continuityError)('NO_CANDIDATES', `No candidates for key "${key}"`));
|
|
37
|
+
}
|
|
38
|
+
if (candidates.length === 1) {
|
|
39
|
+
return (0, shared_1.ok)({ winner: candidates[0], reason: 'single candidate' });
|
|
40
|
+
}
|
|
41
|
+
let winner = candidates[0];
|
|
42
|
+
let winnerTier = (0, trust_js_1.effectiveTier)(winner);
|
|
43
|
+
for (let i = 1; i < candidates.length; i++) {
|
|
44
|
+
const candidate = candidates[i];
|
|
45
|
+
const candidateTier = (0, trust_js_1.effectiveTier)(candidate);
|
|
46
|
+
const comparison = compareCandidates(winner, winnerTier, candidate, candidateTier);
|
|
47
|
+
if (comparison < 0) {
|
|
48
|
+
winner = candidate;
|
|
49
|
+
winnerTier = candidateTier;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const reason = buildReason(winner, winnerTier, candidates.length);
|
|
53
|
+
return (0, shared_1.ok)({ winner, reason });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
exports.PolicyAdjudicator = PolicyAdjudicator;
|
|
57
|
+
/**
|
|
58
|
+
* Consensus-based adjudicator for multi-agent scenarios.
|
|
59
|
+
*
|
|
60
|
+
* Gathers views from participating agents via ViewProvider,
|
|
61
|
+
* then applies majority voting with policy fallback.
|
|
62
|
+
* Requires a quorum (> 50% of views must agree).
|
|
63
|
+
*/
|
|
64
|
+
class ConsensusAdjudicator {
|
|
65
|
+
viewProvider;
|
|
66
|
+
policyFallback = new PolicyAdjudicator();
|
|
67
|
+
constructor(viewProvider) {
|
|
68
|
+
this.viewProvider = viewProvider;
|
|
69
|
+
}
|
|
70
|
+
resolve(key, candidates) {
|
|
71
|
+
// Synchronous interface — consensus requires async gatherViews.
|
|
72
|
+
// For synchronous resolve(), fall back to policy adjudication.
|
|
73
|
+
// Use resolveAsync() for full consensus.
|
|
74
|
+
return this.policyFallback.resolve(key, candidates);
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Async resolution with full multi-agent consensus.
|
|
78
|
+
*
|
|
79
|
+
* @param key - The contested key
|
|
80
|
+
* @param candidates - Local candidates
|
|
81
|
+
* @returns Consensus-resolved winner
|
|
82
|
+
*/
|
|
83
|
+
async resolveAsync(key, candidates) {
|
|
84
|
+
try {
|
|
85
|
+
const views = await this.viewProvider.gatherViews(key);
|
|
86
|
+
if (views.length === 0) {
|
|
87
|
+
return this.policyFallback.resolve(key, candidates);
|
|
88
|
+
}
|
|
89
|
+
// Combine local candidates with remote views
|
|
90
|
+
const allEntries = [
|
|
91
|
+
...candidates,
|
|
92
|
+
...views.map(v => v.entry),
|
|
93
|
+
];
|
|
94
|
+
if (allEntries.length === 0) {
|
|
95
|
+
return (0, shared_1.err)((0, errors_js_1.continuityError)('NO_CANDIDATES', `No candidates for key "${key}"`));
|
|
96
|
+
}
|
|
97
|
+
// Count votes by value (group entries with same effective value)
|
|
98
|
+
const voteGroups = groupByValue(allEntries);
|
|
99
|
+
const totalVotes = allEntries.length;
|
|
100
|
+
const quorum = Math.floor(totalVotes / 2) + 1;
|
|
101
|
+
// Find group with most votes
|
|
102
|
+
let bestGroup = voteGroups[0];
|
|
103
|
+
for (const group of voteGroups) {
|
|
104
|
+
if (group.count > bestGroup.count) {
|
|
105
|
+
bestGroup = group;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (bestGroup.count >= quorum) {
|
|
109
|
+
// Quorum reached — use the best entry from the winning group
|
|
110
|
+
const groupResult = this.policyFallback.resolve(key, bestGroup.entries);
|
|
111
|
+
if (!groupResult.ok)
|
|
112
|
+
return groupResult;
|
|
113
|
+
return (0, shared_1.ok)({
|
|
114
|
+
winner: groupResult.value.winner,
|
|
115
|
+
reason: `consensus: ${bestGroup.count}/${totalVotes} votes (quorum=${quorum})`,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
// No quorum — fall back to policy
|
|
119
|
+
return (0, shared_1.err)((0, errors_js_1.continuityError)('QUORUM_NOT_MET', `Best group has ${bestGroup.count}/${totalVotes} votes, need ${quorum}`));
|
|
120
|
+
}
|
|
121
|
+
catch (e) {
|
|
122
|
+
return (0, shared_1.err)((0, errors_js_1.continuityError)('CONSENSUS_FAILED', `Consensus failed: ${e instanceof Error ? e.message : String(e)}`));
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
exports.ConsensusAdjudicator = ConsensusAdjudicator;
|
|
127
|
+
/* ── Internal helpers ── */
|
|
128
|
+
/**
|
|
129
|
+
* Compare two candidates using total ordering.
|
|
130
|
+
* Returns > 0 if a wins, < 0 if b wins, 0 if tied (shouldn't happen with DID tiebreaker).
|
|
131
|
+
*/
|
|
132
|
+
function compareCandidates(a, aTier, b, bTier) {
|
|
133
|
+
// 1. Higher tier wins
|
|
134
|
+
const tierDiff = types_js_1.TRUST_TIER_RANK[aTier] - types_js_1.TRUST_TIER_RANK[bTier];
|
|
135
|
+
if (tierDiff !== 0)
|
|
136
|
+
return tierDiff;
|
|
137
|
+
// 2. Newer timestamp wins
|
|
138
|
+
const aTime = a.provenance?.timestamp ?? 0;
|
|
139
|
+
const bTime = b.provenance?.timestamp ?? 0;
|
|
140
|
+
if (aTime !== bTime)
|
|
141
|
+
return aTime - bTime;
|
|
142
|
+
// 3. Lexicographically lowest author DID wins (deterministic tiebreaker)
|
|
143
|
+
const aAuthor = a.provenance?.author ?? '';
|
|
144
|
+
const bAuthor = b.provenance?.author ?? '';
|
|
145
|
+
if (aAuthor !== bAuthor) {
|
|
146
|
+
// Lower DID wins — so if a < b, a wins (return > 0)
|
|
147
|
+
return aAuthor < bAuthor ? 1 : -1;
|
|
148
|
+
}
|
|
149
|
+
return 0;
|
|
150
|
+
}
|
|
151
|
+
function buildReason(winner, tier, candidateCount) {
|
|
152
|
+
const parts = [`tier=${tier}`];
|
|
153
|
+
if (winner.provenance) {
|
|
154
|
+
parts.push(`author=${winner.provenance.author.slice(0, 8)}...`);
|
|
155
|
+
parts.push(`ts=${winner.provenance.timestamp}`);
|
|
156
|
+
}
|
|
157
|
+
return `policy: selected from ${candidateCount} candidates (${parts.join(', ')})`;
|
|
158
|
+
}
|
|
159
|
+
function groupByValue(entries) {
|
|
160
|
+
const groups = new Map();
|
|
161
|
+
for (const entry of entries) {
|
|
162
|
+
const valueKey = canonicalValueKey(entry.value);
|
|
163
|
+
const group = groups.get(valueKey);
|
|
164
|
+
if (group) {
|
|
165
|
+
group.push(entry);
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
groups.set(valueKey, [entry]);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return Array.from(groups.values()).map(entries => ({
|
|
172
|
+
entries,
|
|
173
|
+
count: entries.length,
|
|
174
|
+
}));
|
|
175
|
+
}
|
|
176
|
+
function canonicalValueKey(value) {
|
|
177
|
+
if (value === null)
|
|
178
|
+
return 'null';
|
|
179
|
+
if (value instanceof Uint8Array) {
|
|
180
|
+
let s = 'bytes:';
|
|
181
|
+
for (let i = 0; i < value.length; i++)
|
|
182
|
+
s += String.fromCharCode(value[i]);
|
|
183
|
+
return s;
|
|
184
|
+
}
|
|
185
|
+
if (typeof value === 'object') {
|
|
186
|
+
return JSON.stringify(value, Object.keys(value).sort());
|
|
187
|
+
}
|
|
188
|
+
return `${typeof value}:${String(value)}`;
|
|
189
|
+
}
|