koota-kit 0.8.8 → 0.9.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/CHANGELOG.md +10 -0
- package/docs/INTEGRATION_PROOF.md +37 -0
- package/examples/coordination-proof/browser-cohort.mjs +17 -0
- package/examples/coordination-proof/browser-observer.mjs +24 -0
- package/examples/coordination-proof/cohort-core.mjs +417 -0
- package/examples/coordination-proof/cohort.mjs +6 -400
- package/examples/coordination-proof/consumer-package.json +2 -1
- package/examples/coordination-proof/fixture.mjs +3 -28
- package/examples/coordination-proof/mesh-input.mjs +27 -0
- package/examples/coordination-proof/react-inspector.mjs +19 -0
- package/examples/coordination-proof/react-observer.mjs +8 -17
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.0 (2026-10-14)
|
|
4
|
+
|
|
5
|
+
<!-- Release notes generated using configuration in .github/release.yml at main -->
|
|
6
|
+
|
|
7
|
+
## What's Changed
|
|
8
|
+
* feat(integration): extract browser-compatible caller adapters by @jbdevprimary in https://github.com/jbcom/koota-kit/pull/80
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
**Full Changelog**: https://github.com/jbcom/koota-kit/compare/v0.8.8...v0.9.0
|
|
12
|
+
|
|
3
13
|
## 0.8.8 (2026-10-14)
|
|
4
14
|
|
|
5
15
|
<!-- Release notes generated using configuration in .github/release.yml at main -->
|
|
@@ -119,3 +119,40 @@ The pinned 0.8.6 composition uses dense arrays with own indexed entries. The
|
|
|
119
119
|
prototype-filled sparse-array defect tracked in [issue 68](https://github.com/jbcom/koota-kit/issues/68)
|
|
120
120
|
is outside this legitimate-input proof; this guide does not claim that defect
|
|
121
121
|
has been repaired in the installed release.
|
|
122
|
+
|
|
123
|
+
## Browser caller boundary
|
|
124
|
+
|
|
125
|
+
The packed example source has browser-safe siblings, without adding package
|
|
126
|
+
runtime exports or dependencies. `mesh-input.mjs` contains the unchanged mesh
|
|
127
|
+
validation and authored face mapping. `cohort-core.mjs` retains the resource
|
|
128
|
+
ledger and requires synchronous `hashBytes(chunks)` returning lowercase SHA256
|
|
129
|
+
hex. `cohort.mjs` preserves the Node API with `node:crypto`; the fixture disk
|
|
130
|
+
transport and JSDOM observer remain Node-only.
|
|
131
|
+
|
|
132
|
+
The protocol hashes ordered chunks without delimiters. Typed views contribute
|
|
133
|
+
exactly their buffer offset and byte length in native byte order. Other values
|
|
134
|
+
contribute UTF-8 bytes of unchanged `JSON.stringify`, including insertion order.
|
|
135
|
+
Undefined serialization and cycles reject before hashing. Promise or malformed
|
|
136
|
+
hex results reject before publication; asynchronous Web Crypto is unsuitable.
|
|
137
|
+
|
|
138
|
+
A private browser caller imports `sha256` from its pinned
|
|
139
|
+
`@noble/hashes/sha2.js` 2.4.0 install and supplies that primitive to
|
|
140
|
+
`createBrowserProofCohort(coordination, specifications, sha256)` in
|
|
141
|
+
`browser-cohort.mjs`. The package itself does not resolve or depend on Noble.
|
|
142
|
+
The caller must compare its actual installed primitive with the original Node
|
|
143
|
+
hashes for empty, ordered multichunk, offset, Unicode and typed-array vectors.
|
|
144
|
+
|
|
145
|
+
`react-inspector.mjs` supplies the same component to both observers. It checks
|
|
146
|
+
`useWorld()` against the exact raw World and subscribes to the committed cohort.
|
|
147
|
+
`browser-observer.mjs` mounts it in an existing native DOM container using the
|
|
148
|
+
caller's installed React, `createRoot`, raw `WorldProvider` and `useWorld`.
|
|
149
|
+
Its returned cleanup only unmounts React. The private Humanoid route retains
|
|
150
|
+
ownership of its evaluator, producer, rendering and GPU evidence.
|
|
151
|
+
|
|
152
|
+
Resolve example source from hash-verified physical files in the installed
|
|
153
|
+
registry package, or an explicitly recorded immutable source transport. These
|
|
154
|
+
files are packed caller examples, not runtime export subpaths. No workspace
|
|
155
|
+
alias, additional geometry registry or private fixture publication is needed.
|
|
156
|
+
Browser digest equality, same-World observation and retained leases must be
|
|
157
|
+
verified in the actual browser realm; Node adapter tests alone do not establish
|
|
158
|
+
live rendered acceptance.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { createProofCohort as createCoreCohort } from "./cohort-core.mjs";
|
|
2
|
+
export { PROOF_FRAME } from "./cohort-core.mjs";
|
|
3
|
+
|
|
4
|
+
/** Supply sha256 from the private consumer's pinned @noble/hashes/sha2.js install. */
|
|
5
|
+
export function createBrowserHashBytes(sha256) {
|
|
6
|
+
return (chunks) => {
|
|
7
|
+
const hash = sha256.create();
|
|
8
|
+
for (const chunk of chunks) hash.update(chunk);
|
|
9
|
+
return Array.from(hash.digest(), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function createBrowserProofCohort(coordination, specifications, sha256) {
|
|
14
|
+
return createCoreCohort(coordination, specifications, {
|
|
15
|
+
hashBytes: createBrowserHashBytes(sha256),
|
|
16
|
+
});
|
|
17
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { createProofInspector } from "./react-inspector.mjs";
|
|
2
|
+
|
|
3
|
+
/** Native DOM mounting on the private caller route; no JSDOM or global replacement. */
|
|
4
|
+
export function mountProofInspector({
|
|
5
|
+
React,
|
|
6
|
+
createRoot,
|
|
7
|
+
WorldProvider,
|
|
8
|
+
useWorld,
|
|
9
|
+
container,
|
|
10
|
+
cohort,
|
|
11
|
+
world,
|
|
12
|
+
onObservation,
|
|
13
|
+
}) {
|
|
14
|
+
const Inspector = createProofInspector({ React, useWorld });
|
|
15
|
+
const root = createRoot(container);
|
|
16
|
+
root.render(
|
|
17
|
+
React.createElement(
|
|
18
|
+
WorldProvider,
|
|
19
|
+
{ world },
|
|
20
|
+
React.createElement(Inspector, { cohort, world, onObservation }),
|
|
21
|
+
),
|
|
22
|
+
);
|
|
23
|
+
return () => root.unmount();
|
|
24
|
+
}
|
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
import { assertMesh, sourceFaceIds } from "./mesh-input.mjs";
|
|
2
|
+
import {
|
|
3
|
+
indexedBounds,
|
|
4
|
+
nearestOpenSurface,
|
|
5
|
+
NumericalOracleRangeError,
|
|
6
|
+
worldPositions,
|
|
7
|
+
} from "./open-surface.mjs";
|
|
8
|
+
|
|
9
|
+
export const PROOF_FRAME = Object.freeze({
|
|
10
|
+
id: "caller-proof-world",
|
|
11
|
+
unit: "metre",
|
|
12
|
+
metric: "euclidean",
|
|
13
|
+
});
|
|
14
|
+
const freeze = Object.freeze;
|
|
15
|
+
const tuple = (r) => JSON.stringify([r.geometry, r.pose, r.transform]);
|
|
16
|
+
const stampKey = (r, s) =>
|
|
17
|
+
JSON.stringify([
|
|
18
|
+
r.identity.sourceId,
|
|
19
|
+
r.identity.instanceId,
|
|
20
|
+
r.identity.partId,
|
|
21
|
+
r.identity.registration.id,
|
|
22
|
+
r.identity.registration.generation,
|
|
23
|
+
[r.frame.id, r.frame.unit, r.frame.metric],
|
|
24
|
+
s,
|
|
25
|
+
r.topology[s].id,
|
|
26
|
+
[r.revisions.geometry, r.revisions.topology, r.revisions.pose, r.revisions.transform],
|
|
27
|
+
]);
|
|
28
|
+
/** Preserve the Node reference protocol: ordered exact views and unchanged JSON UTF-8. */
|
|
29
|
+
export function hashValues(hashBytes, ...values) {
|
|
30
|
+
if (typeof hashBytes !== "function") throw new TypeError("proof: synchronous hashBytes required");
|
|
31
|
+
const encoder = new TextEncoder();
|
|
32
|
+
const chunks = values.map((value) => {
|
|
33
|
+
if (ArrayBuffer.isView(value))
|
|
34
|
+
return new Uint8Array(value.buffer, value.byteOffset, value.byteLength).slice();
|
|
35
|
+
const json = JSON.stringify(value);
|
|
36
|
+
if (json === undefined) throw new TypeError("proof: value has no JSON serialization");
|
|
37
|
+
return encoder.encode(json);
|
|
38
|
+
});
|
|
39
|
+
const digest = hashBytes(chunks);
|
|
40
|
+
if (typeof digest !== "string" || !/^[0-9a-f]{64}$/.test(digest))
|
|
41
|
+
throw new TypeError("proof: hashBytes must return synchronous lowercase SHA256 hex");
|
|
42
|
+
return digest;
|
|
43
|
+
}
|
|
44
|
+
function sameIndexBytes(left, right) {
|
|
45
|
+
if (left.length !== right.length || left.byteLength !== right.byteLength) return false;
|
|
46
|
+
const a = new Uint8Array(left.buffer, left.byteOffset, left.byteLength);
|
|
47
|
+
const b = new Uint8Array(right.buffer, right.byteOffset, right.byteLength);
|
|
48
|
+
for (let index = 0; index < a.length; index += 1) if (a[index] !== b[index]) return false;
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Test-only caller resource ledger; coordination snapshots are the sole commit boundary. */
|
|
53
|
+
export function createProofCohort(coordination, specifications, { hashBytes } = {}) {
|
|
54
|
+
if (typeof hashBytes !== "function") throw new TypeError("proof: synchronous hashBytes required");
|
|
55
|
+
const hashArrays = (...values) => hashValues(hashBytes, ...values);
|
|
56
|
+
const models = new Map(),
|
|
57
|
+
views = new WeakMap();
|
|
58
|
+
let busy = false,
|
|
59
|
+
disposalStarted = false;
|
|
60
|
+
function current(m) {
|
|
61
|
+
return (
|
|
62
|
+
coordination.getRegistration(m.id) === m.token && coordination.getEntity(m.id) === m.entity
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
function selectedVersion(m, s) {
|
|
66
|
+
const e = s.entries.find((e) => e.id === m.id);
|
|
67
|
+
return e?.completed ? m.versions.get(tuple(e.completed.revisions)) : undefined;
|
|
68
|
+
}
|
|
69
|
+
function collect() {
|
|
70
|
+
if (busy) return;
|
|
71
|
+
const s = coordination.getSnapshot();
|
|
72
|
+
for (const m of models.values()) {
|
|
73
|
+
const active = current(m) ? selectedVersion(m, s) : undefined;
|
|
74
|
+
for (const [id, v] of m.versions) if (v !== active && v.leases === 0) m.versions.delete(id);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function view(s) {
|
|
78
|
+
function invalid() {
|
|
79
|
+
const cached = views.get(s);
|
|
80
|
+
if (cached?.valid === false) return cached;
|
|
81
|
+
const result = freeze({ coordination: s, records: freeze([]), valid: false });
|
|
82
|
+
views.set(s, result);
|
|
83
|
+
return result;
|
|
84
|
+
}
|
|
85
|
+
if ([...models.values()].some((m) => !m.removed && !current(m))) return invalid();
|
|
86
|
+
// Cached metadata does not lease buffers; verify retained versions before using it.
|
|
87
|
+
if (
|
|
88
|
+
[...models.values()].some(
|
|
89
|
+
(m) =>
|
|
90
|
+
!m.removed &&
|
|
91
|
+
(!s.entries.find((entry) => entry.id === m.id)?.completed || !selectedVersion(m, s)),
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
return invalid();
|
|
95
|
+
const cached = views.get(s);
|
|
96
|
+
if (cached?.valid === true) return cached;
|
|
97
|
+
const records = [];
|
|
98
|
+
for (const m of models.values()) {
|
|
99
|
+
if (m.removed) continue;
|
|
100
|
+
const v = selectedVersion(m, s);
|
|
101
|
+
if (s.entries.find((entry) => entry.id === m.id)?.completed && !v) {
|
|
102
|
+
return invalid();
|
|
103
|
+
}
|
|
104
|
+
if (v) records.push(...v.parts.map((p) => p.record));
|
|
105
|
+
}
|
|
106
|
+
records.sort(
|
|
107
|
+
(a, b) =>
|
|
108
|
+
Number(stampKey(a, "full") > stampKey(b, "full")) -
|
|
109
|
+
Number(stampKey(a, "full") < stampKey(b, "full")),
|
|
110
|
+
);
|
|
111
|
+
const result = freeze({ coordination: s, records: freeze(records), valid: true });
|
|
112
|
+
views.set(s, result);
|
|
113
|
+
return result;
|
|
114
|
+
}
|
|
115
|
+
function prepare(m, u) {
|
|
116
|
+
if (!current(m)) throw new Error("proof: stale owned registration lifetime");
|
|
117
|
+
const revisions = freeze({ ...u.revisions }),
|
|
118
|
+
previous = selectedVersion(m, coordination.getSnapshot());
|
|
119
|
+
for (const epoch of ["geometry", "topology", "pose", "transform"]) {
|
|
120
|
+
if (
|
|
121
|
+
!Number.isSafeInteger(revisions[epoch]) ||
|
|
122
|
+
revisions[epoch] < 0 ||
|
|
123
|
+
(previous && revisions[epoch] < previous.revisions[epoch])
|
|
124
|
+
)
|
|
125
|
+
throw new Error(`proof: invalid or regressing ${epoch} epoch`);
|
|
126
|
+
}
|
|
127
|
+
if (
|
|
128
|
+
previous &&
|
|
129
|
+
revisions.topology !== previous.revisions.topology &&
|
|
130
|
+
revisions.geometry === previous.revisions.geometry
|
|
131
|
+
)
|
|
132
|
+
throw new Error("proof: topology replacement must advance geometry epoch");
|
|
133
|
+
const frame = freeze({ ...(u.frame ?? m.frame ?? PROOF_FRAME) });
|
|
134
|
+
if (
|
|
135
|
+
typeof frame.id !== "string" ||
|
|
136
|
+
frame.id.length === 0 ||
|
|
137
|
+
typeof frame.unit !== "string" ||
|
|
138
|
+
frame.unit.length === 0 ||
|
|
139
|
+
frame.metric !== "euclidean"
|
|
140
|
+
)
|
|
141
|
+
throw new Error("proof: invalid declared frame");
|
|
142
|
+
const partIds = new Set();
|
|
143
|
+
const parts = u.parts.map((p) => {
|
|
144
|
+
if (!p.id || partIds.has(p.id) || !p.fullTopologyId || !p.visibleTopologyId)
|
|
145
|
+
throw new Error("proof: invalid or duplicate part/topology identity");
|
|
146
|
+
partIds.add(p.id);
|
|
147
|
+
assertMesh(p.positions, p.full, p.positions.length / 3);
|
|
148
|
+
assertMesh(p.positions, p.visible, p.positions.length / 3);
|
|
149
|
+
const full = new Uint32Array(p.full),
|
|
150
|
+
visible = new Uint32Array(p.visible),
|
|
151
|
+
sourcePositions = new Float32Array(p.positions);
|
|
152
|
+
assertMesh(sourcePositions, full, sourcePositions.length / 3);
|
|
153
|
+
assertMesh(sourcePositions, visible, sourcePositions.length / 3);
|
|
154
|
+
const previousPart = previous?.parts.find((part) => part.record.identity.partId === p.id);
|
|
155
|
+
// Reuse only a committed same-lifetime part after validating fresh copies.
|
|
156
|
+
const canReuse =
|
|
157
|
+
previousPart &&
|
|
158
|
+
previous.revisions.topology === revisions.topology &&
|
|
159
|
+
previousPart.record.identity.sourceId === m.sourceId &&
|
|
160
|
+
previousPart.record.identity.instanceId === m.instanceId &&
|
|
161
|
+
previousPart.record.identity.registration.generation === m.providerGeneration &&
|
|
162
|
+
previousPart.record.topology.full.id === p.fullTopologyId &&
|
|
163
|
+
previousPart.record.topology.visible.id === p.visibleTopologyId &&
|
|
164
|
+
sameIndexBytes(previousPart.full, full) &&
|
|
165
|
+
sameIndexBytes(previousPart.visible, visible);
|
|
166
|
+
const visibleFaces = canReuse ? previousPart.visibleFaces : sourceFaceIds(full, visible),
|
|
167
|
+
positions = worldPositions(sourcePositions, u.toWorld);
|
|
168
|
+
const identity = freeze({
|
|
169
|
+
sourceId: m.sourceId,
|
|
170
|
+
instanceId: m.instanceId,
|
|
171
|
+
partId: p.id,
|
|
172
|
+
registration: freeze({ id: m.id, generation: m.providerGeneration }),
|
|
173
|
+
});
|
|
174
|
+
const record = freeze({
|
|
175
|
+
identity,
|
|
176
|
+
frame,
|
|
177
|
+
revisions,
|
|
178
|
+
topology: freeze({
|
|
179
|
+
full: freeze({
|
|
180
|
+
id: p.fullTopologyId,
|
|
181
|
+
bounds: indexedBounds(positions, full),
|
|
182
|
+
enclosesSurface: true,
|
|
183
|
+
}),
|
|
184
|
+
visible: freeze({
|
|
185
|
+
id: p.visibleTopologyId,
|
|
186
|
+
bounds: indexedBounds(positions, visible),
|
|
187
|
+
enclosesSurface: true,
|
|
188
|
+
}),
|
|
189
|
+
}),
|
|
190
|
+
capabilities: freeze(p.nearestSurface === false ? [] : ["nearest-surface"]),
|
|
191
|
+
});
|
|
192
|
+
return {
|
|
193
|
+
record,
|
|
194
|
+
sourcePositions,
|
|
195
|
+
positions,
|
|
196
|
+
full,
|
|
197
|
+
visible,
|
|
198
|
+
visibleFaces,
|
|
199
|
+
provenance: p.provenance ?? null,
|
|
200
|
+
};
|
|
201
|
+
});
|
|
202
|
+
const topologyHash = hashArrays(
|
|
203
|
+
...parts.flatMap((p) => [
|
|
204
|
+
p.record.identity,
|
|
205
|
+
p.record.topology.full.id,
|
|
206
|
+
p.record.topology.visible.id,
|
|
207
|
+
p.full,
|
|
208
|
+
p.visible,
|
|
209
|
+
]),
|
|
210
|
+
);
|
|
211
|
+
if (
|
|
212
|
+
previous &&
|
|
213
|
+
previous.topologyHash !== topologyHash &&
|
|
214
|
+
revisions.geometry === previous.revisions.geometry
|
|
215
|
+
)
|
|
216
|
+
throw new Error("proof: topology bytes/identity replacement must advance geometry epoch");
|
|
217
|
+
const fingerprint = hashArrays(
|
|
218
|
+
revisions,
|
|
219
|
+
[...u.toWorld],
|
|
220
|
+
...parts.flatMap((p) => [p.record, p.provenance, p.sourcePositions, p.full, p.visible]),
|
|
221
|
+
);
|
|
222
|
+
const existing = m.versions.get(tuple(revisions));
|
|
223
|
+
if (existing) {
|
|
224
|
+
if (existing.fingerprint !== fingerprint)
|
|
225
|
+
throw new Error("proof: changed content under the same completed tuple");
|
|
226
|
+
return existing;
|
|
227
|
+
}
|
|
228
|
+
return { revisions, parts, topologyHash, fingerprint, leases: 0 };
|
|
229
|
+
}
|
|
230
|
+
const specificationIds = new Set();
|
|
231
|
+
for (const specification of specifications) {
|
|
232
|
+
for (const field of ["id", "sourceId", "instanceId"])
|
|
233
|
+
if (typeof specification[field] !== "string" || specification[field].length === 0)
|
|
234
|
+
throw new Error("proof: invalid model identity");
|
|
235
|
+
if (
|
|
236
|
+
!Number.isSafeInteger(specification.providerGeneration) ||
|
|
237
|
+
specification.providerGeneration < 0
|
|
238
|
+
)
|
|
239
|
+
throw new Error("proof: invalid provider generation");
|
|
240
|
+
if (specificationIds.has(specification.id)) throw new Error("proof: duplicate model identity");
|
|
241
|
+
if (coordination.getRegistration(specification.id))
|
|
242
|
+
throw new Error("proof: pre-existing caller registration");
|
|
243
|
+
specificationIds.add(specification.id);
|
|
244
|
+
}
|
|
245
|
+
for (const specification of specifications) {
|
|
246
|
+
if (models.has(specification.id)) throw new Error("proof: duplicate model identity");
|
|
247
|
+
const m = { ...specification, token: undefined, versions: new Map(), removed: false };
|
|
248
|
+
models.set(m.id, m);
|
|
249
|
+
const previousToken = coordination.getRegistration(m.id);
|
|
250
|
+
try {
|
|
251
|
+
m.token = coordination.register(m.entity, { id: m.id, capabilities: ["open-surface"] });
|
|
252
|
+
} catch (error) {
|
|
253
|
+
if (
|
|
254
|
+
coordination.getEntity(m.id) === m.entity &&
|
|
255
|
+
coordination.getRegistration(m.id) !== previousToken
|
|
256
|
+
)
|
|
257
|
+
m.token = coordination.getRegistration(m.id);
|
|
258
|
+
const errors = [error];
|
|
259
|
+
for (const owned of models.values())
|
|
260
|
+
if (owned.token && coordination.getRegistration(owned.id) === owned.token) {
|
|
261
|
+
try {
|
|
262
|
+
coordination.unregister(owned.token);
|
|
263
|
+
} catch (e) {
|
|
264
|
+
errors.push(e);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
throw new AggregateError(
|
|
268
|
+
errors,
|
|
269
|
+
"proof: construction failed; owned token recovery attempted",
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
function publish(updates) {
|
|
274
|
+
if (busy) throw new Error("proof: publication/disposal reentry");
|
|
275
|
+
if (disposalStarted) throw new Error("proof: disposed cohort");
|
|
276
|
+
busy = true;
|
|
277
|
+
try {
|
|
278
|
+
const seen = new Set();
|
|
279
|
+
const prepared = updates.map((u) => {
|
|
280
|
+
const m = models.get(u.id);
|
|
281
|
+
if (!m || seen.has(u.id)) throw new Error("proof: unknown or duplicate model update");
|
|
282
|
+
seen.add(u.id);
|
|
283
|
+
return { m, v: prepare(m, u) };
|
|
284
|
+
});
|
|
285
|
+
// Native trait callbacks select OLD; committed subscribers select NEW, from this same ledger.
|
|
286
|
+
for (const { m, v } of prepared) m.versions.set(tuple(v.revisions), v);
|
|
287
|
+
coordination.publishBatch(
|
|
288
|
+
prepared.map(({ m, v }) => ({
|
|
289
|
+
token: m.token,
|
|
290
|
+
completed: {
|
|
291
|
+
revisions: {
|
|
292
|
+
geometry: v.revisions.geometry,
|
|
293
|
+
pose: v.revisions.pose,
|
|
294
|
+
transform: v.revisions.transform,
|
|
295
|
+
},
|
|
296
|
+
},
|
|
297
|
+
})),
|
|
298
|
+
);
|
|
299
|
+
return view(coordination.getSnapshot());
|
|
300
|
+
} finally {
|
|
301
|
+
busy = false;
|
|
302
|
+
collect();
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
function capture(snapshot = coordination.getSnapshot()) {
|
|
306
|
+
if (disposalStarted) throw new Error("proof: disposed cohort cannot acquire fresh captures");
|
|
307
|
+
const selected = view(snapshot),
|
|
308
|
+
versions = [],
|
|
309
|
+
partsByStamp = new Map(),
|
|
310
|
+
diagnostics = [];
|
|
311
|
+
if (selected.valid)
|
|
312
|
+
for (const m of models.values()) {
|
|
313
|
+
if (m.removed) continue;
|
|
314
|
+
const v = selectedVersion(m, snapshot);
|
|
315
|
+
if (!v) continue;
|
|
316
|
+
v.leases += 1;
|
|
317
|
+
versions.push(v);
|
|
318
|
+
for (const p of v.parts)
|
|
319
|
+
for (const s of ["full", "visible"]) partsByStamp.set(stampKey(p.record, s), p);
|
|
320
|
+
}
|
|
321
|
+
let released = false;
|
|
322
|
+
const assertLease = () => {
|
|
323
|
+
if (released) throw new Error("proof: released capture");
|
|
324
|
+
};
|
|
325
|
+
return freeze({
|
|
326
|
+
snapshot: selected,
|
|
327
|
+
provider: freeze({
|
|
328
|
+
snapshot() {
|
|
329
|
+
assertLease();
|
|
330
|
+
return selected.valid ? selected.records : [freeze({})];
|
|
331
|
+
},
|
|
332
|
+
}),
|
|
333
|
+
oracle: freeze({
|
|
334
|
+
nearest({ record, topology, point, maximumDistance }) {
|
|
335
|
+
assertLease();
|
|
336
|
+
const p = partsByStamp.get(stampKey(record, topology));
|
|
337
|
+
if (!p) return undefined;
|
|
338
|
+
const consumed = freeze({
|
|
339
|
+
identity: record.identity,
|
|
340
|
+
frame: record.frame,
|
|
341
|
+
topology: freeze({ selection: topology, id: record.topology[topology].id }),
|
|
342
|
+
revisions: record.revisions,
|
|
343
|
+
});
|
|
344
|
+
let hit;
|
|
345
|
+
try {
|
|
346
|
+
hit = nearestOpenSurface(
|
|
347
|
+
p.positions,
|
|
348
|
+
p[topology],
|
|
349
|
+
point,
|
|
350
|
+
topology === "visible" ? p.visibleFaces : undefined,
|
|
351
|
+
);
|
|
352
|
+
} catch (error) {
|
|
353
|
+
if (error instanceof NumericalOracleRangeError)
|
|
354
|
+
return freeze({ kind: "unknown", reason: "numerical-oracle-range", consumed });
|
|
355
|
+
throw error;
|
|
356
|
+
}
|
|
357
|
+
if (!hit || hit.distance > maximumDistance) return freeze({ kind: "no-hit", consumed });
|
|
358
|
+
diagnostics.push(freeze({ identity: record.identity, ...hit, consumed }));
|
|
359
|
+
return freeze({
|
|
360
|
+
kind: "nearest",
|
|
361
|
+
witness: freeze({
|
|
362
|
+
triangleId: `${record.identity.partId}/full-face/${hit.face}`,
|
|
363
|
+
point: hit.point,
|
|
364
|
+
distance: hit.distance,
|
|
365
|
+
frame: record.frame,
|
|
366
|
+
consumed,
|
|
367
|
+
}),
|
|
368
|
+
});
|
|
369
|
+
},
|
|
370
|
+
}),
|
|
371
|
+
getDiagnostics: () => freeze([...diagnostics]),
|
|
372
|
+
release() {
|
|
373
|
+
if (released) return;
|
|
374
|
+
released = true;
|
|
375
|
+
for (const v of versions) v.leases -= 1;
|
|
376
|
+
partsByStamp.clear();
|
|
377
|
+
collect();
|
|
378
|
+
},
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
function dispose() {
|
|
382
|
+
if (busy) throw new Error("proof: publication/disposal reentry");
|
|
383
|
+
busy = true;
|
|
384
|
+
disposalStarted = true;
|
|
385
|
+
const errors = [];
|
|
386
|
+
try {
|
|
387
|
+
for (const m of models.values()) {
|
|
388
|
+
if (m.removed) continue;
|
|
389
|
+
if (coordination.getRegistration(m.id) !== m.token) {
|
|
390
|
+
m.removed = true;
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
try {
|
|
394
|
+
coordination.unregister(m.token);
|
|
395
|
+
} catch (e) {
|
|
396
|
+
errors.push(e);
|
|
397
|
+
}
|
|
398
|
+
m.removed = coordination.getRegistration(m.id) !== m.token;
|
|
399
|
+
}
|
|
400
|
+
} finally {
|
|
401
|
+
busy = false;
|
|
402
|
+
collect();
|
|
403
|
+
}
|
|
404
|
+
if (errors.length)
|
|
405
|
+
throw new AggregateError(
|
|
406
|
+
errors,
|
|
407
|
+
"proof: disposal errors; failed owned cleanup remains retryable",
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
return freeze({
|
|
411
|
+
publish,
|
|
412
|
+
capture,
|
|
413
|
+
dispose,
|
|
414
|
+
getSnapshot: () => view(coordination.getSnapshot()),
|
|
415
|
+
subscribe: (listener) => coordination.subscribe(() => listener()),
|
|
416
|
+
});
|
|
417
|
+
}
|
|
@@ -1,408 +1,14 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
indexedBounds,
|
|
5
|
-
nearestOpenSurface,
|
|
6
|
-
NumericalOracleRangeError,
|
|
7
|
-
worldPositions,
|
|
8
|
-
} from "./open-surface.mjs";
|
|
2
|
+
import { createProofCohort as createCoreCohort } from "./cohort-core.mjs";
|
|
3
|
+
export { PROOF_FRAME } from "./cohort-core.mjs";
|
|
9
4
|
|
|
10
|
-
export
|
|
11
|
-
id: "caller-proof-world",
|
|
12
|
-
unit: "metre",
|
|
13
|
-
metric: "euclidean",
|
|
14
|
-
});
|
|
15
|
-
const freeze = Object.freeze;
|
|
16
|
-
const tuple = (r) => JSON.stringify([r.geometry, r.pose, r.transform]);
|
|
17
|
-
const stampKey = (r, s) =>
|
|
18
|
-
JSON.stringify([
|
|
19
|
-
r.identity.sourceId,
|
|
20
|
-
r.identity.instanceId,
|
|
21
|
-
r.identity.partId,
|
|
22
|
-
r.identity.registration.id,
|
|
23
|
-
r.identity.registration.generation,
|
|
24
|
-
[r.frame.id, r.frame.unit, r.frame.metric],
|
|
25
|
-
s,
|
|
26
|
-
r.topology[s].id,
|
|
27
|
-
[r.revisions.geometry, r.revisions.topology, r.revisions.pose, r.revisions.transform],
|
|
28
|
-
]);
|
|
29
|
-
function hashArrays(...values) {
|
|
5
|
+
export function hashBytes(chunks) {
|
|
30
6
|
const hash = createHash("sha256");
|
|
31
|
-
for (const
|
|
32
|
-
hash.update(
|
|
33
|
-
ArrayBuffer.isView(v) ? Buffer.from(v.buffer, v.byteOffset, v.byteLength) : JSON.stringify(v),
|
|
34
|
-
);
|
|
7
|
+
for (const chunk of chunks) hash.update(chunk);
|
|
35
8
|
return hash.digest("hex");
|
|
36
9
|
}
|
|
37
|
-
function sameIndexBytes(left, right) {
|
|
38
|
-
if (left.length !== right.length || left.byteLength !== right.byteLength) return false;
|
|
39
|
-
const a = new Uint8Array(left.buffer, left.byteOffset, left.byteLength);
|
|
40
|
-
const b = new Uint8Array(right.buffer, right.byteOffset, right.byteLength);
|
|
41
|
-
for (let index = 0; index < a.length; index += 1) if (a[index] !== b[index]) return false;
|
|
42
|
-
return true;
|
|
43
|
-
}
|
|
44
10
|
|
|
45
|
-
/**
|
|
11
|
+
/** Node reference API; only the hash primitive differs from the browser caller. */
|
|
46
12
|
export function createProofCohort(coordination, specifications) {
|
|
47
|
-
|
|
48
|
-
views = new WeakMap();
|
|
49
|
-
let busy = false,
|
|
50
|
-
disposalStarted = false;
|
|
51
|
-
function current(m) {
|
|
52
|
-
return (
|
|
53
|
-
coordination.getRegistration(m.id) === m.token && coordination.getEntity(m.id) === m.entity
|
|
54
|
-
);
|
|
55
|
-
}
|
|
56
|
-
function selectedVersion(m, s) {
|
|
57
|
-
const e = s.entries.find((e) => e.id === m.id);
|
|
58
|
-
return e?.completed ? m.versions.get(tuple(e.completed.revisions)) : undefined;
|
|
59
|
-
}
|
|
60
|
-
function collect() {
|
|
61
|
-
if (busy) return;
|
|
62
|
-
const s = coordination.getSnapshot();
|
|
63
|
-
for (const m of models.values()) {
|
|
64
|
-
const active = current(m) ? selectedVersion(m, s) : undefined;
|
|
65
|
-
for (const [id, v] of m.versions) if (v !== active && v.leases === 0) m.versions.delete(id);
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
function view(s) {
|
|
69
|
-
function invalid() {
|
|
70
|
-
const cached = views.get(s);
|
|
71
|
-
if (cached?.valid === false) return cached;
|
|
72
|
-
const result = freeze({ coordination: s, records: freeze([]), valid: false });
|
|
73
|
-
views.set(s, result);
|
|
74
|
-
return result;
|
|
75
|
-
}
|
|
76
|
-
if ([...models.values()].some((m) => !m.removed && !current(m))) return invalid();
|
|
77
|
-
// Cached metadata does not lease buffers; verify retained versions before using it.
|
|
78
|
-
if (
|
|
79
|
-
[...models.values()].some(
|
|
80
|
-
(m) =>
|
|
81
|
-
!m.removed &&
|
|
82
|
-
(!s.entries.find((entry) => entry.id === m.id)?.completed || !selectedVersion(m, s)),
|
|
83
|
-
)
|
|
84
|
-
)
|
|
85
|
-
return invalid();
|
|
86
|
-
const cached = views.get(s);
|
|
87
|
-
if (cached?.valid === true) return cached;
|
|
88
|
-
const records = [];
|
|
89
|
-
for (const m of models.values()) {
|
|
90
|
-
if (m.removed) continue;
|
|
91
|
-
const v = selectedVersion(m, s);
|
|
92
|
-
if (s.entries.find((entry) => entry.id === m.id)?.completed && !v) {
|
|
93
|
-
return invalid();
|
|
94
|
-
}
|
|
95
|
-
if (v) records.push(...v.parts.map((p) => p.record));
|
|
96
|
-
}
|
|
97
|
-
records.sort(
|
|
98
|
-
(a, b) =>
|
|
99
|
-
Number(stampKey(a, "full") > stampKey(b, "full")) -
|
|
100
|
-
Number(stampKey(a, "full") < stampKey(b, "full")),
|
|
101
|
-
);
|
|
102
|
-
const result = freeze({ coordination: s, records: freeze(records), valid: true });
|
|
103
|
-
views.set(s, result);
|
|
104
|
-
return result;
|
|
105
|
-
}
|
|
106
|
-
function prepare(m, u) {
|
|
107
|
-
if (!current(m)) throw new Error("proof: stale owned registration lifetime");
|
|
108
|
-
const revisions = freeze({ ...u.revisions }),
|
|
109
|
-
previous = selectedVersion(m, coordination.getSnapshot());
|
|
110
|
-
for (const epoch of ["geometry", "topology", "pose", "transform"]) {
|
|
111
|
-
if (
|
|
112
|
-
!Number.isSafeInteger(revisions[epoch]) ||
|
|
113
|
-
revisions[epoch] < 0 ||
|
|
114
|
-
(previous && revisions[epoch] < previous.revisions[epoch])
|
|
115
|
-
)
|
|
116
|
-
throw new Error(`proof: invalid or regressing ${epoch} epoch`);
|
|
117
|
-
}
|
|
118
|
-
if (
|
|
119
|
-
previous &&
|
|
120
|
-
revisions.topology !== previous.revisions.topology &&
|
|
121
|
-
revisions.geometry === previous.revisions.geometry
|
|
122
|
-
)
|
|
123
|
-
throw new Error("proof: topology replacement must advance geometry epoch");
|
|
124
|
-
const frame = freeze({ ...(u.frame ?? m.frame ?? PROOF_FRAME) });
|
|
125
|
-
if (
|
|
126
|
-
typeof frame.id !== "string" ||
|
|
127
|
-
frame.id.length === 0 ||
|
|
128
|
-
typeof frame.unit !== "string" ||
|
|
129
|
-
frame.unit.length === 0 ||
|
|
130
|
-
frame.metric !== "euclidean"
|
|
131
|
-
)
|
|
132
|
-
throw new Error("proof: invalid declared frame");
|
|
133
|
-
const partIds = new Set();
|
|
134
|
-
const parts = u.parts.map((p) => {
|
|
135
|
-
if (!p.id || partIds.has(p.id) || !p.fullTopologyId || !p.visibleTopologyId)
|
|
136
|
-
throw new Error("proof: invalid or duplicate part/topology identity");
|
|
137
|
-
partIds.add(p.id);
|
|
138
|
-
assertMesh(p.positions, p.full, p.positions.length / 3);
|
|
139
|
-
assertMesh(p.positions, p.visible, p.positions.length / 3);
|
|
140
|
-
const full = new Uint32Array(p.full),
|
|
141
|
-
visible = new Uint32Array(p.visible),
|
|
142
|
-
sourcePositions = new Float32Array(p.positions);
|
|
143
|
-
assertMesh(sourcePositions, full, sourcePositions.length / 3);
|
|
144
|
-
assertMesh(sourcePositions, visible, sourcePositions.length / 3);
|
|
145
|
-
const previousPart = previous?.parts.find((part) => part.record.identity.partId === p.id);
|
|
146
|
-
// Reuse only a committed same-lifetime part after validating fresh copies.
|
|
147
|
-
const canReuse =
|
|
148
|
-
previousPart &&
|
|
149
|
-
previous.revisions.topology === revisions.topology &&
|
|
150
|
-
previousPart.record.identity.sourceId === m.sourceId &&
|
|
151
|
-
previousPart.record.identity.instanceId === m.instanceId &&
|
|
152
|
-
previousPart.record.identity.registration.generation === m.providerGeneration &&
|
|
153
|
-
previousPart.record.topology.full.id === p.fullTopologyId &&
|
|
154
|
-
previousPart.record.topology.visible.id === p.visibleTopologyId &&
|
|
155
|
-
sameIndexBytes(previousPart.full, full) &&
|
|
156
|
-
sameIndexBytes(previousPart.visible, visible);
|
|
157
|
-
const visibleFaces = canReuse ? previousPart.visibleFaces : sourceFaceIds(full, visible),
|
|
158
|
-
positions = worldPositions(sourcePositions, u.toWorld);
|
|
159
|
-
const identity = freeze({
|
|
160
|
-
sourceId: m.sourceId,
|
|
161
|
-
instanceId: m.instanceId,
|
|
162
|
-
partId: p.id,
|
|
163
|
-
registration: freeze({ id: m.id, generation: m.providerGeneration }),
|
|
164
|
-
});
|
|
165
|
-
const record = freeze({
|
|
166
|
-
identity,
|
|
167
|
-
frame,
|
|
168
|
-
revisions,
|
|
169
|
-
topology: freeze({
|
|
170
|
-
full: freeze({
|
|
171
|
-
id: p.fullTopologyId,
|
|
172
|
-
bounds: indexedBounds(positions, full),
|
|
173
|
-
enclosesSurface: true,
|
|
174
|
-
}),
|
|
175
|
-
visible: freeze({
|
|
176
|
-
id: p.visibleTopologyId,
|
|
177
|
-
bounds: indexedBounds(positions, visible),
|
|
178
|
-
enclosesSurface: true,
|
|
179
|
-
}),
|
|
180
|
-
}),
|
|
181
|
-
capabilities: freeze(p.nearestSurface === false ? [] : ["nearest-surface"]),
|
|
182
|
-
});
|
|
183
|
-
return {
|
|
184
|
-
record,
|
|
185
|
-
sourcePositions,
|
|
186
|
-
positions,
|
|
187
|
-
full,
|
|
188
|
-
visible,
|
|
189
|
-
visibleFaces,
|
|
190
|
-
provenance: p.provenance ?? null,
|
|
191
|
-
};
|
|
192
|
-
});
|
|
193
|
-
const topologyHash = hashArrays(
|
|
194
|
-
...parts.flatMap((p) => [
|
|
195
|
-
p.record.identity,
|
|
196
|
-
p.record.topology.full.id,
|
|
197
|
-
p.record.topology.visible.id,
|
|
198
|
-
p.full,
|
|
199
|
-
p.visible,
|
|
200
|
-
]),
|
|
201
|
-
);
|
|
202
|
-
if (
|
|
203
|
-
previous &&
|
|
204
|
-
previous.topologyHash !== topologyHash &&
|
|
205
|
-
revisions.geometry === previous.revisions.geometry
|
|
206
|
-
)
|
|
207
|
-
throw new Error("proof: topology bytes/identity replacement must advance geometry epoch");
|
|
208
|
-
const fingerprint = hashArrays(
|
|
209
|
-
revisions,
|
|
210
|
-
[...u.toWorld],
|
|
211
|
-
...parts.flatMap((p) => [p.record, p.provenance, p.sourcePositions, p.full, p.visible]),
|
|
212
|
-
);
|
|
213
|
-
const existing = m.versions.get(tuple(revisions));
|
|
214
|
-
if (existing) {
|
|
215
|
-
if (existing.fingerprint !== fingerprint)
|
|
216
|
-
throw new Error("proof: changed content under the same completed tuple");
|
|
217
|
-
return existing;
|
|
218
|
-
}
|
|
219
|
-
return { revisions, parts, topologyHash, fingerprint, leases: 0 };
|
|
220
|
-
}
|
|
221
|
-
const specificationIds = new Set();
|
|
222
|
-
for (const specification of specifications) {
|
|
223
|
-
for (const field of ["id", "sourceId", "instanceId"])
|
|
224
|
-
if (typeof specification[field] !== "string" || specification[field].length === 0)
|
|
225
|
-
throw new Error("proof: invalid model identity");
|
|
226
|
-
if (
|
|
227
|
-
!Number.isSafeInteger(specification.providerGeneration) ||
|
|
228
|
-
specification.providerGeneration < 0
|
|
229
|
-
)
|
|
230
|
-
throw new Error("proof: invalid provider generation");
|
|
231
|
-
if (specificationIds.has(specification.id)) throw new Error("proof: duplicate model identity");
|
|
232
|
-
if (coordination.getRegistration(specification.id))
|
|
233
|
-
throw new Error("proof: pre-existing caller registration");
|
|
234
|
-
specificationIds.add(specification.id);
|
|
235
|
-
}
|
|
236
|
-
for (const specification of specifications) {
|
|
237
|
-
if (models.has(specification.id)) throw new Error("proof: duplicate model identity");
|
|
238
|
-
const m = { ...specification, token: undefined, versions: new Map(), removed: false };
|
|
239
|
-
models.set(m.id, m);
|
|
240
|
-
const previousToken = coordination.getRegistration(m.id);
|
|
241
|
-
try {
|
|
242
|
-
m.token = coordination.register(m.entity, { id: m.id, capabilities: ["open-surface"] });
|
|
243
|
-
} catch (error) {
|
|
244
|
-
if (
|
|
245
|
-
coordination.getEntity(m.id) === m.entity &&
|
|
246
|
-
coordination.getRegistration(m.id) !== previousToken
|
|
247
|
-
)
|
|
248
|
-
m.token = coordination.getRegistration(m.id);
|
|
249
|
-
const errors = [error];
|
|
250
|
-
for (const owned of models.values())
|
|
251
|
-
if (owned.token && coordination.getRegistration(owned.id) === owned.token) {
|
|
252
|
-
try {
|
|
253
|
-
coordination.unregister(owned.token);
|
|
254
|
-
} catch (e) {
|
|
255
|
-
errors.push(e);
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
throw new AggregateError(
|
|
259
|
-
errors,
|
|
260
|
-
"proof: construction failed; owned token recovery attempted",
|
|
261
|
-
);
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
function publish(updates) {
|
|
265
|
-
if (busy) throw new Error("proof: publication/disposal reentry");
|
|
266
|
-
if (disposalStarted) throw new Error("proof: disposed cohort");
|
|
267
|
-
busy = true;
|
|
268
|
-
try {
|
|
269
|
-
const seen = new Set();
|
|
270
|
-
const prepared = updates.map((u) => {
|
|
271
|
-
const m = models.get(u.id);
|
|
272
|
-
if (!m || seen.has(u.id)) throw new Error("proof: unknown or duplicate model update");
|
|
273
|
-
seen.add(u.id);
|
|
274
|
-
return { m, v: prepare(m, u) };
|
|
275
|
-
});
|
|
276
|
-
// Native trait callbacks select OLD; committed subscribers select NEW, from this same ledger.
|
|
277
|
-
for (const { m, v } of prepared) m.versions.set(tuple(v.revisions), v);
|
|
278
|
-
coordination.publishBatch(
|
|
279
|
-
prepared.map(({ m, v }) => ({
|
|
280
|
-
token: m.token,
|
|
281
|
-
completed: {
|
|
282
|
-
revisions: {
|
|
283
|
-
geometry: v.revisions.geometry,
|
|
284
|
-
pose: v.revisions.pose,
|
|
285
|
-
transform: v.revisions.transform,
|
|
286
|
-
},
|
|
287
|
-
},
|
|
288
|
-
})),
|
|
289
|
-
);
|
|
290
|
-
return view(coordination.getSnapshot());
|
|
291
|
-
} finally {
|
|
292
|
-
busy = false;
|
|
293
|
-
collect();
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
function capture(snapshot = coordination.getSnapshot()) {
|
|
297
|
-
if (disposalStarted) throw new Error("proof: disposed cohort cannot acquire fresh captures");
|
|
298
|
-
const selected = view(snapshot),
|
|
299
|
-
versions = [],
|
|
300
|
-
partsByStamp = new Map(),
|
|
301
|
-
diagnostics = [];
|
|
302
|
-
if (selected.valid)
|
|
303
|
-
for (const m of models.values()) {
|
|
304
|
-
if (m.removed) continue;
|
|
305
|
-
const v = selectedVersion(m, snapshot);
|
|
306
|
-
if (!v) continue;
|
|
307
|
-
v.leases += 1;
|
|
308
|
-
versions.push(v);
|
|
309
|
-
for (const p of v.parts)
|
|
310
|
-
for (const s of ["full", "visible"]) partsByStamp.set(stampKey(p.record, s), p);
|
|
311
|
-
}
|
|
312
|
-
let released = false;
|
|
313
|
-
const assertLease = () => {
|
|
314
|
-
if (released) throw new Error("proof: released capture");
|
|
315
|
-
};
|
|
316
|
-
return freeze({
|
|
317
|
-
snapshot: selected,
|
|
318
|
-
provider: freeze({
|
|
319
|
-
snapshot() {
|
|
320
|
-
assertLease();
|
|
321
|
-
return selected.valid ? selected.records : [freeze({})];
|
|
322
|
-
},
|
|
323
|
-
}),
|
|
324
|
-
oracle: freeze({
|
|
325
|
-
nearest({ record, topology, point, maximumDistance }) {
|
|
326
|
-
assertLease();
|
|
327
|
-
const p = partsByStamp.get(stampKey(record, topology));
|
|
328
|
-
if (!p) return undefined;
|
|
329
|
-
const consumed = freeze({
|
|
330
|
-
identity: record.identity,
|
|
331
|
-
frame: record.frame,
|
|
332
|
-
topology: freeze({ selection: topology, id: record.topology[topology].id }),
|
|
333
|
-
revisions: record.revisions,
|
|
334
|
-
});
|
|
335
|
-
let hit;
|
|
336
|
-
try {
|
|
337
|
-
hit = nearestOpenSurface(
|
|
338
|
-
p.positions,
|
|
339
|
-
p[topology],
|
|
340
|
-
point,
|
|
341
|
-
topology === "visible" ? p.visibleFaces : undefined,
|
|
342
|
-
);
|
|
343
|
-
} catch (error) {
|
|
344
|
-
if (error instanceof NumericalOracleRangeError)
|
|
345
|
-
return freeze({ kind: "unknown", reason: "numerical-oracle-range", consumed });
|
|
346
|
-
throw error;
|
|
347
|
-
}
|
|
348
|
-
if (!hit || hit.distance > maximumDistance) return freeze({ kind: "no-hit", consumed });
|
|
349
|
-
diagnostics.push(freeze({ identity: record.identity, ...hit, consumed }));
|
|
350
|
-
return freeze({
|
|
351
|
-
kind: "nearest",
|
|
352
|
-
witness: freeze({
|
|
353
|
-
triangleId: `${record.identity.partId}/full-face/${hit.face}`,
|
|
354
|
-
point: hit.point,
|
|
355
|
-
distance: hit.distance,
|
|
356
|
-
frame: record.frame,
|
|
357
|
-
consumed,
|
|
358
|
-
}),
|
|
359
|
-
});
|
|
360
|
-
},
|
|
361
|
-
}),
|
|
362
|
-
getDiagnostics: () => freeze([...diagnostics]),
|
|
363
|
-
release() {
|
|
364
|
-
if (released) return;
|
|
365
|
-
released = true;
|
|
366
|
-
for (const v of versions) v.leases -= 1;
|
|
367
|
-
partsByStamp.clear();
|
|
368
|
-
collect();
|
|
369
|
-
},
|
|
370
|
-
});
|
|
371
|
-
}
|
|
372
|
-
function dispose() {
|
|
373
|
-
if (busy) throw new Error("proof: publication/disposal reentry");
|
|
374
|
-
busy = true;
|
|
375
|
-
disposalStarted = true;
|
|
376
|
-
const errors = [];
|
|
377
|
-
try {
|
|
378
|
-
for (const m of models.values()) {
|
|
379
|
-
if (m.removed) continue;
|
|
380
|
-
if (coordination.getRegistration(m.id) !== m.token) {
|
|
381
|
-
m.removed = true;
|
|
382
|
-
continue;
|
|
383
|
-
}
|
|
384
|
-
try {
|
|
385
|
-
coordination.unregister(m.token);
|
|
386
|
-
} catch (e) {
|
|
387
|
-
errors.push(e);
|
|
388
|
-
}
|
|
389
|
-
m.removed = coordination.getRegistration(m.id) !== m.token;
|
|
390
|
-
}
|
|
391
|
-
} finally {
|
|
392
|
-
busy = false;
|
|
393
|
-
collect();
|
|
394
|
-
}
|
|
395
|
-
if (errors.length)
|
|
396
|
-
throw new AggregateError(
|
|
397
|
-
errors,
|
|
398
|
-
"proof: disposal errors; failed owned cleanup remains retryable",
|
|
399
|
-
);
|
|
400
|
-
}
|
|
401
|
-
return freeze({
|
|
402
|
-
publish,
|
|
403
|
-
capture,
|
|
404
|
-
dispose,
|
|
405
|
-
getSnapshot: () => view(coordination.getSnapshot()),
|
|
406
|
-
subscribe: (listener) => coordination.subscribe(() => listener()),
|
|
407
|
-
});
|
|
13
|
+
return createCoreCohort(coordination, specifications, { hashBytes });
|
|
408
14
|
}
|
|
@@ -2,6 +2,9 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
3
|
import { basename, join } from "node:path";
|
|
4
4
|
|
|
5
|
+
import { assertMesh, sourceFaceIds } from "./mesh-input.mjs";
|
|
6
|
+
export { assertMesh, sourceFaceIds } from "./mesh-input.mjs";
|
|
7
|
+
|
|
5
8
|
const types = {
|
|
6
9
|
Float32Array: [Float32Array, 4, "getFloat32"],
|
|
7
10
|
Uint32Array: [Uint32Array, 4, "getUint32"],
|
|
@@ -33,34 +36,6 @@ export function decodeArray(bytes, descriptor) {
|
|
|
33
36
|
return array;
|
|
34
37
|
}
|
|
35
38
|
|
|
36
|
-
export function assertMesh(positions, indices, vertexCount) {
|
|
37
|
-
if (positions.length !== vertexCount * 3 || indices.length % 3 !== 0) {
|
|
38
|
-
throw new Error("fixture: malformed mesh layout");
|
|
39
|
-
}
|
|
40
|
-
for (const index of indices) {
|
|
41
|
-
if (!Number.isInteger(index) || index < 0 || index >= vertexCount) {
|
|
42
|
-
throw new Error("fixture: index out of range");
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/** Preserve authored full-source face ordinals even for render-visible selection. */
|
|
48
|
-
export function sourceFaceIds(full, visible) {
|
|
49
|
-
const faces = new Map();
|
|
50
|
-
for (let offset = 0; offset < full.length; offset += 3) {
|
|
51
|
-
const key = `${full[offset]},${full[offset + 1]},${full[offset + 2]}`;
|
|
52
|
-
if (faces.has(key)) throw new Error("fixture: duplicate full source face");
|
|
53
|
-
faces.set(key, offset / 3);
|
|
54
|
-
}
|
|
55
|
-
const selected = new Uint32Array(visible.length / 3);
|
|
56
|
-
for (let offset = 0; offset < visible.length; offset += 3) {
|
|
57
|
-
const id = faces.get(`${visible[offset]},${visible[offset + 1]},${visible[offset + 2]}`);
|
|
58
|
-
if (id === undefined) throw new Error("fixture: visible face absent from full source");
|
|
59
|
-
selected[offset / 3] = id;
|
|
60
|
-
}
|
|
61
|
-
return selected;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
39
|
/** Fixture transport only: this does not evaluate poses or claim a live HK adapter. */
|
|
65
40
|
export async function loadFigureFixture(directory) {
|
|
66
41
|
const manifestBytes = await readFile(join(directory, "fixture.json"));
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export function assertMesh(positions, indices, vertexCount) {
|
|
2
|
+
if (positions.length !== vertexCount * 3 || indices.length % 3 !== 0) {
|
|
3
|
+
throw new Error("fixture: malformed mesh layout");
|
|
4
|
+
}
|
|
5
|
+
for (const index of indices) {
|
|
6
|
+
if (!Number.isInteger(index) || index < 0 || index >= vertexCount) {
|
|
7
|
+
throw new Error("fixture: index out of range");
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Preserve authored full-source face ordinals even for render-visible selection. */
|
|
13
|
+
export function sourceFaceIds(full, visible) {
|
|
14
|
+
const faces = new Map();
|
|
15
|
+
for (let offset = 0; offset < full.length; offset += 3) {
|
|
16
|
+
const key = `${full[offset]},${full[offset + 1]},${full[offset + 2]}`;
|
|
17
|
+
if (faces.has(key)) throw new Error("fixture: duplicate full source face");
|
|
18
|
+
faces.set(key, offset / 3);
|
|
19
|
+
}
|
|
20
|
+
const selected = new Uint32Array(visible.length / 3);
|
|
21
|
+
for (let offset = 0; offset < visible.length; offset += 3) {
|
|
22
|
+
const id = faces.get(`${visible[offset]},${visible[offset + 1]},${visible[offset + 2]}`);
|
|
23
|
+
if (id === undefined) throw new Error("fixture: visible face absent from full source");
|
|
24
|
+
selected[offset / 3] = id;
|
|
25
|
+
}
|
|
26
|
+
return selected;
|
|
27
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Shared raw-koota World inspector; rendering and unmount never own the World. */
|
|
2
|
+
export function createProofInspector({ React, useWorld }) {
|
|
3
|
+
return function Inspector({ cohort, world, onObservation }) {
|
|
4
|
+
if (useWorld() !== world) throw new Error("proof: inspector observed a different raw World");
|
|
5
|
+
const snapshot = React.useSyncExternalStore(
|
|
6
|
+
cohort.subscribe,
|
|
7
|
+
cohort.getSnapshot,
|
|
8
|
+
cohort.getSnapshot,
|
|
9
|
+
);
|
|
10
|
+
onObservation?.(snapshot);
|
|
11
|
+
return React.createElement(
|
|
12
|
+
"output",
|
|
13
|
+
null,
|
|
14
|
+
snapshot.coordination.entries
|
|
15
|
+
.map((entry) => `${entry.id}:${entry.completed?.revisions.pose ?? "pending"}`)
|
|
16
|
+
.join(","),
|
|
17
|
+
);
|
|
18
|
+
};
|
|
19
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
+
import { createProofInspector } from "./react-inspector.mjs";
|
|
2
3
|
|
|
3
4
|
/** A headless mounted observer; neither the component nor its unmount owns the World. */
|
|
4
5
|
export async function observeCommittedUpdate({ registryImport, cohort, world, publish }) {
|
|
@@ -19,26 +20,16 @@ export async function observeCommittedUpdate({ registryImport, cohort, world, pu
|
|
|
19
20
|
const observations = [];
|
|
20
21
|
const container = dom.window.document.getElementById("proof");
|
|
21
22
|
const root = createRoot(container);
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
);
|
|
29
|
-
observations.push(snapshot);
|
|
30
|
-
return React.createElement(
|
|
31
|
-
"output",
|
|
32
|
-
null,
|
|
33
|
-
snapshot.coordination.entries
|
|
34
|
-
.map((entry) => `${entry.id}:${entry.completed?.revisions.pose ?? "pending"}`)
|
|
35
|
-
.join(","),
|
|
36
|
-
);
|
|
37
|
-
}
|
|
23
|
+
const Inspector = createProofInspector({ React, useWorld });
|
|
24
|
+
const inspector = React.createElement(Inspector, {
|
|
25
|
+
cohort,
|
|
26
|
+
world,
|
|
27
|
+
onObservation: (snapshot) => observations.push(snapshot),
|
|
28
|
+
});
|
|
38
29
|
let unmounted = false;
|
|
39
30
|
try {
|
|
40
31
|
await React.act(async () =>
|
|
41
|
-
root.render(React.createElement(WorldProvider, { world },
|
|
32
|
+
root.render(React.createElement(WorldProvider, { world }, inspector)),
|
|
42
33
|
);
|
|
43
34
|
const before = container.textContent;
|
|
44
35
|
const previous = cohort.getSnapshot();
|
package/package.json
CHANGED