@fundamental-engine/core 0.10.1 → 0.10.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/.metadata_never_index +0 -0
- package/dist/.noindex +0 -0
- package/dist/config/manual.d.ts.map +1 -1
- package/dist/config/manual.js +18 -0
- package/dist/config/manual.js.map +1 -1
- package/dist/config/presets.d.ts +10 -2
- package/dist/config/presets.d.ts.map +1 -1
- package/dist/config/presets.js +26 -2
- package/dist/config/presets.js.map +1 -1
- package/dist/conformance/experiments.d.ts.map +1 -1
- package/dist/conformance/experiments.js +91 -0
- package/dist/conformance/experiments.js.map +1 -1
- package/dist/conformance/run.d.ts.map +1 -1
- package/dist/conformance/run.js +34 -4
- package/dist/conformance/run.js.map +1 -1
- package/dist/conformance/types.d.ts +12 -0
- package/dist/conformance/types.d.ts.map +1 -1
- package/dist/contracts/passport.d.ts.map +1 -1
- package/dist/contracts/passport.js +1 -0
- package/dist/contracts/passport.js.map +1 -1
- package/dist/diagnostics/probes.d.ts.map +1 -1
- package/dist/diagnostics/probes.js +5 -0
- package/dist/diagnostics/probes.js.map +1 -1
- package/dist/engine/agent-read-share.d.ts +34 -0
- package/dist/engine/agent-read-share.d.ts.map +1 -0
- package/dist/engine/agent-read-share.js +50 -0
- package/dist/engine/agent-read-share.js.map +1 -0
- package/dist/engine/field.d.ts.map +1 -1
- package/dist/engine/field.js +170 -102
- package/dist/engine/field.js.map +1 -1
- package/dist/engine/integrator.d.ts +12 -1
- package/dist/engine/integrator.d.ts.map +1 -1
- package/dist/engine/integrator.js +44 -0
- package/dist/engine/integrator.js.map +1 -1
- package/dist/engine/scalar-grid.d.ts +25 -2
- package/dist/engine/scalar-grid.d.ts.map +1 -1
- package/dist/engine/scalar-grid.js +38 -1
- package/dist/engine/scalar-grid.js.map +1 -1
- package/dist/engine/scanner.d.ts +5 -3
- package/dist/engine/scanner.d.ts.map +1 -1
- package/dist/engine/scanner.js +29 -5
- package/dist/engine/scanner.js.map +1 -1
- package/dist/engine/shadow.d.ts +58 -2
- package/dist/engine/shadow.d.ts.map +1 -1
- package/dist/engine/shadow.js +86 -1
- package/dist/engine/shadow.js.map +1 -1
- package/dist/engine/snapshot-policy.d.ts +28 -0
- package/dist/engine/snapshot-policy.d.ts.map +1 -0
- package/dist/engine/snapshot-policy.js +95 -0
- package/dist/engine/snapshot-policy.js.map +1 -0
- package/dist/engine/streamlines.d.ts +24 -7
- package/dist/engine/streamlines.d.ts.map +1 -1
- package/dist/engine/streamlines.js +96 -5
- package/dist/engine/streamlines.js.map +1 -1
- package/dist/engine/types.d.ts +98 -8
- package/dist/engine/types.d.ts.map +1 -1
- package/dist/forces/extended.d.ts +1 -0
- package/dist/forces/extended.d.ts.map +1 -1
- package/dist/forces/extended.js +68 -0
- package/dist/forces/extended.js.map +1 -1
- package/dist/forces/index.d.ts.map +1 -1
- package/dist/forces/index.js +4 -1
- package/dist/forces/index.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +6 -6
package/dist/engine/field.js
CHANGED
|
@@ -49,6 +49,7 @@ import { forceAt, netField } from "./streamlines.js";
|
|
|
49
49
|
import { traceFieldLines } from "./fieldlines.js";
|
|
50
50
|
import { fieldLineSeeds } from "./fieldline-seeds.js";
|
|
51
51
|
import { flowBiasInto, makeFlowFocus } from "./flow.js";
|
|
52
|
+
import { admits as admitsShare } from "./agent-read-share.js";
|
|
52
53
|
import { devWarnNoOp } from "../contracts/guards.js";
|
|
53
54
|
import { FIELD_VERSION } from "../version.js";
|
|
54
55
|
import { energyReport } from "../diagnostics/energy.js";
|
|
@@ -56,100 +57,13 @@ import { accumulateAt } from "../diagnostics/probes.js";
|
|
|
56
57
|
import { diffFieldSnapshots, replayFieldSnapshots } from "./field-snapshot.js";
|
|
57
58
|
import { lintProjections } from "./governance.js";
|
|
58
59
|
import { applyLens } from "./query-lens.js";
|
|
60
|
+
import { clonePolicy, applyRedactions, resolveSnapshotInclusion } from "./snapshot-policy.js";
|
|
59
61
|
// Shared draw/integrate scratch — reused across the per-particle and per-cell hot loops so an
|
|
60
62
|
// active flow focus and the particle draw don't allocate a `{x,y}` / `[r,g,b]` each iteration.
|
|
61
63
|
// Safe to share module-wide: each field's frame runs synchronously, and every read consumes the
|
|
62
64
|
// scratch before the next write (no overlapping lifetimes, no cross-instance interleaving).
|
|
63
65
|
const _flowB = { x: 0, y: 0 };
|
|
64
66
|
const _rgb = [0, 0, 0];
|
|
65
|
-
/** Deep-copy a {@link FieldPolicy} (shallow is unsafe — `budgets` is nested). `undefined` → `{}` (the
|
|
66
|
-
* unbounded default). Used on set + read so callers can neither mutate the field's live policy nor
|
|
67
|
-
* observe later mutations of the object they passed in. */
|
|
68
|
-
function clonePolicy(p) {
|
|
69
|
-
if (!p)
|
|
70
|
-
return {};
|
|
71
|
-
const out = {};
|
|
72
|
-
if (p.allowBodyDataInSnapshots != null)
|
|
73
|
-
out.allowBodyDataInSnapshots = p.allowBodyDataInSnapshots;
|
|
74
|
-
if (p.allowMotionProjection != null)
|
|
75
|
-
out.allowMotionProjection = p.allowMotionProjection;
|
|
76
|
-
if (p.maxMotionBudget != null)
|
|
77
|
-
out.maxMotionBudget = p.maxMotionBudget;
|
|
78
|
-
if (p.budgets)
|
|
79
|
-
out.budgets = { ...p.budgets };
|
|
80
|
-
return out;
|
|
81
|
-
}
|
|
82
|
-
/**
|
|
83
|
-
* Resolve {@link FieldSnapshotOptions} — an optional {@link SnapshotProfile} composed with the explicit
|
|
84
|
-
* `include*` flags — to concrete inclusion, TIGHTEST-wins. A profile establishes a baseline; an explicit
|
|
85
|
-
* flag may tighten it further but never widen it (an explicit `true` cannot re-enable what the profile
|
|
86
|
-
* turned off). `includeData` additionally passes through the policy gate at the call site (this only
|
|
87
|
-
* governs whether the CALLER asked for it). Relationships default true when nothing narrows them.
|
|
88
|
-
*/
|
|
89
|
-
function resolveSnapshotInclusion(opts) {
|
|
90
|
-
// Per-profile baselines. `debug` = everything; `agent` = structure + attribution, no opaque data;
|
|
91
|
-
// `bug-report` = structural + versions, no data; `public` = ids + shape only.
|
|
92
|
-
const base = {
|
|
93
|
-
debug: { includeParticles: true, includeRelationships: true, includeData: true, includeInfluences: true },
|
|
94
|
-
agent: { includeParticles: false, includeRelationships: true, includeData: false, includeInfluences: true },
|
|
95
|
-
'bug-report': { includeParticles: false, includeRelationships: true, includeData: false, includeInfluences: true },
|
|
96
|
-
public: { includeParticles: false, includeRelationships: false, includeData: false, includeInfluences: false },
|
|
97
|
-
};
|
|
98
|
-
const p = opts.profile;
|
|
99
|
-
if (!p) {
|
|
100
|
-
// No profile: today's defaults — relationships default true, the rest default false.
|
|
101
|
-
return {
|
|
102
|
-
includeParticles: opts.includeParticles === true,
|
|
103
|
-
includeRelationships: opts.includeRelationships !== false,
|
|
104
|
-
includeData: opts.includeData === true,
|
|
105
|
-
includeInfluences: opts.includeInfluences === true,
|
|
106
|
-
};
|
|
107
|
-
}
|
|
108
|
-
const b = base[p];
|
|
109
|
-
// TIGHTEST wins: a flag is on only if the profile allows it AND the caller didn't explicitly turn it
|
|
110
|
-
// off. An explicit `true` can never widen past the profile's baseline.
|
|
111
|
-
return {
|
|
112
|
-
includeParticles: b.includeParticles && opts.includeParticles !== false,
|
|
113
|
-
includeRelationships: b.includeRelationships && opts.includeRelationships !== false,
|
|
114
|
-
includeData: b.includeData && opts.includeData !== false,
|
|
115
|
-
includeInfluences: b.includeInfluences && opts.includeInfluences !== false,
|
|
116
|
-
};
|
|
117
|
-
}
|
|
118
|
-
/**
|
|
119
|
-
* Strip a set of dotted `redactions` paths from a plain reading (query result or snapshot). A top-level
|
|
120
|
-
* key (`'metrics'`, `'relationships'`) deletes that key from the object. A `<prefix>.<key>` path where
|
|
121
|
-
* prefix ∈ {body, relationship, influence, projection} strips `<key>` from each entry of the matching
|
|
122
|
-
* list; `body.data` strips per-body `data`. Mutates the passed object (it's always a fresh result/copy).
|
|
123
|
-
*/
|
|
124
|
-
function applyRedactions(reading, redactions) {
|
|
125
|
-
const listKeyFor = { body: 'bodies', relationship: 'relationships', influence: 'influences', projection: 'projections' };
|
|
126
|
-
for (const path of redactions) {
|
|
127
|
-
const dot = path.indexOf('.');
|
|
128
|
-
if (dot < 0) {
|
|
129
|
-
delete reading[path];
|
|
130
|
-
continue;
|
|
131
|
-
}
|
|
132
|
-
const prefix = path.slice(0, dot);
|
|
133
|
-
const key = path.slice(dot + 1);
|
|
134
|
-
const listKey = listKeyFor[prefix];
|
|
135
|
-
if (listKey && Array.isArray(reading[listKey])) {
|
|
136
|
-
for (const entry of reading[listKey]) {
|
|
137
|
-
if (entry && typeof entry === 'object')
|
|
138
|
-
delete entry[key];
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
else if (prefix === 'metrics' && reading['metrics'] && typeof reading['metrics'] === 'object') {
|
|
142
|
-
delete reading['metrics'][key];
|
|
143
|
-
}
|
|
144
|
-
else {
|
|
145
|
-
// an unrecognized prefix addresses a nested top-level object key.
|
|
146
|
-
const target = reading[prefix];
|
|
147
|
-
if (target && typeof target === 'object')
|
|
148
|
-
delete target[key];
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
return reading;
|
|
152
|
-
}
|
|
153
67
|
// ── Focus / attention substrate (experimental) constants ────────────────────────────────────────
|
|
154
68
|
/** default half-life of a focus deposit, in the field's simulation-clock unit (env.t SECONDS) — attention
|
|
155
69
|
* halves in ~8s. freshness() is unit-agnostic, so at/now/halfLife share env.t's unit (seconds). */
|
|
@@ -219,6 +133,45 @@ export function createField(canvas, opts = {}) {
|
|
|
219
133
|
const store = new FieldStore();
|
|
220
134
|
let nextParticleId = 1; // monotonic stable particle identity (readParticleIds); never reused
|
|
221
135
|
const grids = new Map(); // §20.1 class [C] field buffers, lazy
|
|
136
|
+
// ── declared potentials (#443) ──────────────────────────────────────────────────────────────
|
|
137
|
+
// A host channel (`addField`) admitted as a scalar potential Φ and rasterised into a HELD grid the
|
|
138
|
+
// `relief` force reads as −∇Φ. This raster is the ONE place the engine caches a channel sampler
|
|
139
|
+
// (the amended `addField` contract), so its invalidation set is the entire correctness story:
|
|
140
|
+
//
|
|
141
|
+
// addField(name, …) a channel appeared ─┐
|
|
142
|
+
// handle.set(next) the sampler was swapped │ bump the epoch ⇒ the next read re-rasters
|
|
143
|
+
// handle.remove() the channel is gone │ (remove ALSO drops the grid, so a removed
|
|
144
|
+
// resize() the buffers were rebuilt ─┘ channel can never be read stale)
|
|
145
|
+
//
|
|
146
|
+
// There is deliberately NO schedule. Nothing re-rasters on a frame cadence, so held state cannot be
|
|
147
|
+
// frame-phase dependent — registering a channel on frame 3 gives the same trajectory as on frame 0 —
|
|
148
|
+
// and it cannot outlive its channel. `potentialAt` is pulled BY the force during the force pass and
|
|
149
|
+
// re-rasters exactly when the epoch it last saw has moved. Under reduced motion the integrator
|
|
150
|
+
// returns before the force pass (`dt === 0`), so nothing is sampled at all: the raster freezes with
|
|
151
|
+
// the motion, with no dt special-case here.
|
|
152
|
+
let channelEpoch = 0;
|
|
153
|
+
const potentialStamp = new Map(); // grid key → the epoch its cells were filled at
|
|
154
|
+
const potentialKey = (name) => `potential:${name}`;
|
|
155
|
+
function invalidatePotentials() {
|
|
156
|
+
channelEpoch++;
|
|
157
|
+
}
|
|
158
|
+
function potentialAt(name) {
|
|
159
|
+
const sampler = fieldChannels.get(name);
|
|
160
|
+
if (!sampler)
|
|
161
|
+
return undefined; // never registered, or removed — the force no-ops (never stale)
|
|
162
|
+
const key = potentialKey(name);
|
|
163
|
+
let g = grids.get(key);
|
|
164
|
+
if (!g) {
|
|
165
|
+
g = new ScalarGridImpl(W, H, 'held');
|
|
166
|
+
grids.set(key, g);
|
|
167
|
+
potentialStamp.set(key, -1);
|
|
168
|
+
}
|
|
169
|
+
if (potentialStamp.get(key) !== channelEpoch) {
|
|
170
|
+
g.fillFrom(sampler); // non-finite samples are clamped to 0 inside fillFrom, at the boundary
|
|
171
|
+
potentialStamp.set(key, channelEpoch);
|
|
172
|
+
}
|
|
173
|
+
return g;
|
|
174
|
+
}
|
|
222
175
|
const reg = createRegistry();
|
|
223
176
|
// host-agnostic discrete event bus (the read side): plain-data push for occurrences a non-DOM
|
|
224
177
|
// host reacts to (a sink caught/released matter, the swarm settled) — no DOM, no polling. Cheap
|
|
@@ -328,7 +281,7 @@ export function createField(canvas, opts = {}) {
|
|
|
328
281
|
// through its `data-body` token (e.g. `data-body="lens crystallize"`); an unused force costs nothing.
|
|
329
282
|
registerCoreForces(reg); // the canonical nine (§6)
|
|
330
283
|
registerNaturalForces(reg); // 8 natural primitives: gravity, charge, magnetism, thermal, … (§20.10)
|
|
331
|
-
registerExtendedForces(reg); //
|
|
284
|
+
registerExtendedForces(reg); // 20 designed extended forces: lens, crystallize, link, morph, relief, … (§20.3)
|
|
332
285
|
// the environment seam: all DOM access goes through this injected host — core imports zero DOM.
|
|
333
286
|
// In the browser, pass `browserHost()` from @fundamental-engine/dom (or use createBrowserField); the
|
|
334
287
|
// @fundamental-engine/{elements,react,vanilla} entry points wire it for you.
|
|
@@ -427,6 +380,13 @@ export function createField(canvas, opts = {}) {
|
|
|
427
380
|
// full above start·H, gone by (start+span)·H. Defaults reproduce start=0.3, span=0.85.
|
|
428
381
|
heatmapFadeStart: opts.heatmapFade && Number.isFinite(opts.heatmapFade.start) ? opts.heatmapFade.start : 0.3,
|
|
429
382
|
heatmapFadeSpan: opts.heatmapFade && Number.isFinite(opts.heatmapFade.span) && opts.heatmapFade.span > 0 ? opts.heatmapFade.span : 0.85,
|
|
383
|
+
// the resting-motion floor (declared, default OFF): validated to the two modes; strength ≥ 0 (default 1).
|
|
384
|
+
restingMotion: opts.restingMotion && (opts.restingMotion.mode === 'thermal' || opts.restingMotion.mode === 'flow')
|
|
385
|
+
? {
|
|
386
|
+
mode: opts.restingMotion.mode,
|
|
387
|
+
strength: opts.restingMotion.strength != null && opts.restingMotion.strength >= 0 ? opts.restingMotion.strength : 1,
|
|
388
|
+
}
|
|
389
|
+
: undefined,
|
|
430
390
|
// the integration scheme (substrate doc 04 §Step 3, #659); 'legacy' (default) is the shipped engine.
|
|
431
391
|
integrator: (opts.integrator === 'fixed' || opts.integrator === 'velocity-verlet'
|
|
432
392
|
? opts.integrator
|
|
@@ -926,11 +886,23 @@ export function createField(canvas, opts = {}) {
|
|
|
926
886
|
neighbors: (p, r) => store.neighbors(p, r),
|
|
927
887
|
// scalar field-buffer service (§20.1 class [C]): created on demand, so a page
|
|
928
888
|
// with no diffuse/propagate body allocates nothing. Grids named "wave…" use the
|
|
929
|
-
// wave scheme
|
|
889
|
+
// wave scheme, "memory…" the slow-decay scheme, "potential:…" the HELD raster of a
|
|
890
|
+
// declared potential (#443 — it never steps); everything else diffuses.
|
|
891
|
+
//
|
|
892
|
+
// THE ONE NON-BYTE-IDENTICAL CHANGE in #443, stated rather than buried: a host that was
|
|
893
|
+
// already calling `grid('potential:…')` got a DIFFUSING grid before this change and gets a
|
|
894
|
+
// held one after. No shipped code, recipe or doc opens such a name; it is called out here and
|
|
895
|
+
// in the CHANGELOG because it is the only way any existing field could move.
|
|
930
896
|
grid: (name) => {
|
|
931
897
|
let g = grids.get(name);
|
|
932
898
|
if (!g) {
|
|
933
|
-
const mode = name.startsWith('wave')
|
|
899
|
+
const mode = name.startsWith('wave')
|
|
900
|
+
? 'wave'
|
|
901
|
+
: name.startsWith('memory')
|
|
902
|
+
? 'memory'
|
|
903
|
+
: name.startsWith('potential:')
|
|
904
|
+
? 'held'
|
|
905
|
+
: 'diffuse';
|
|
934
906
|
g = new ScalarGridImpl(W, H, mode);
|
|
935
907
|
grids.set(name, g);
|
|
936
908
|
}
|
|
@@ -1005,10 +977,12 @@ export function createField(canvas, opts = {}) {
|
|
|
1005
977
|
const scanned = scanBodies(host.root);
|
|
1006
978
|
// merge event-registered shadow-DOM hosts (deduped — a light-DOM host that also fires
|
|
1007
979
|
// a registration event is counted once). Registration is the canonical discovery path;
|
|
1008
|
-
// light-DOM scanning is the compatibility fallback (shadow-dom.md §16).
|
|
980
|
+
// light-DOM scanning is the compatibility fallback (shadow-dom.md §16). The scan root is
|
|
981
|
+
// passed so a host registered with `scope: 'nearest'` / a `field` target (§17–§19) is built
|
|
982
|
+
// only by the field that owns it; a detail without those keys is built as before.
|
|
1009
983
|
if (shadow.size > 0) {
|
|
1010
984
|
const seen = new Set(scanned.map((b) => b.el));
|
|
1011
|
-
bodies = scanned.concat(shadow.bodies(bodyFromElement).filter((b) => !seen.has(b.el)));
|
|
985
|
+
bodies = scanned.concat(shadow.bodies(bodyFromElement, host.root).filter((b) => !seen.has(b.el)));
|
|
1012
986
|
}
|
|
1013
987
|
else {
|
|
1014
988
|
bodies = scanned;
|
|
@@ -1653,6 +1627,11 @@ export function createField(canvas, opts = {}) {
|
|
|
1653
1627
|
maxScroll = (host.scrollHeight?.() ?? H) - H || 1;
|
|
1654
1628
|
for (const g of grids.values())
|
|
1655
1629
|
g.resize(W, H); // keep field buffers viewport-sized
|
|
1630
|
+
// resize() rebuilds the buffers and preserves NOTHING — every held raster is now all zeros, i.e.
|
|
1631
|
+
// a flat potential. Invalidate so the next force pass refills it; `relief` would otherwise read a
|
|
1632
|
+
// zero gradient (no transport at all) rather than stale data. The refill happens on the next PULL,
|
|
1633
|
+
// which is necessarily after the scan() below has re-detected the bodies.
|
|
1634
|
+
invalidatePotentials();
|
|
1656
1635
|
if (cfg.heatmap) {
|
|
1657
1636
|
if (!heatmap)
|
|
1658
1637
|
heatmap = new Heatmap(W, H);
|
|
@@ -3023,7 +3002,18 @@ export function createField(canvas, opts = {}) {
|
|
|
3023
3002
|
resolvedWaveCenter = null;
|
|
3024
3003
|
}
|
|
3025
3004
|
updateWarpTargets(); // refresh warp relocate targets from paired bodies (§22.3) before the step
|
|
3026
|
-
|
|
3005
|
+
// `Env.potential` follows the `fieldAt?` / `accum?` precedent: an opt-in service, present only
|
|
3006
|
+
// while some body actually declares `relief`. A field that never declares one — INCLUDING every
|
|
3007
|
+
// field whose host called `addField` — carries no such property, so `applyForce` never sees it and
|
|
3008
|
+
// the default hot path is byte-identical. This is the "registration does not couple" guarantee the
|
|
3009
|
+
// shipped docs make, enforced structurally rather than by a flag. Decided from the live body list
|
|
3010
|
+
// each frame rather than at scan time, because `addBody` appends a programmatic body without a
|
|
3011
|
+
// rescan — deciding in `scan()` would leave a programmatic `relief` body permanently uncoupled.
|
|
3012
|
+
if (bodies.some((b) => b.tokens.includes('relief')))
|
|
3013
|
+
env.potential = potentialAt;
|
|
3014
|
+
else if (env.potential)
|
|
3015
|
+
delete env.potential;
|
|
3016
|
+
step({ store, bodies, env, forces: reg.forces, conditions: reg.conditions, waves, waveStyle: cfg.waveStyle, waveCenter: resolvedWaveCenter, separation: cfg.separation, restingMotion: cfg.restingMotion });
|
|
3027
3017
|
// hover-focus (field.focusAt): hold the focused particle still and light it up — the dwell
|
|
3028
3018
|
// affordance ("it stops and does something") before a click opens its record.
|
|
3029
3019
|
if (focusP) {
|
|
@@ -3266,13 +3256,50 @@ export function createField(canvas, opts = {}) {
|
|
|
3266
3256
|
const caps = new Set(viewOpts.capabilities ?? []);
|
|
3267
3257
|
const redactions = (viewOpts.redactions ?? []).slice();
|
|
3268
3258
|
const has = (c) => caps.has(c);
|
|
3269
|
-
//
|
|
3270
|
-
//
|
|
3271
|
-
//
|
|
3259
|
+
// `budgets.agentRead` is a CONSERVATION LAW on the agent surface (#915): `b` is the SHARE of the
|
|
3260
|
+
// field's readable body population this view may consume. `b == null` or `b >= 1` is the whole
|
|
3261
|
+
// field; `b <= 0` closes the surface entirely (ids + shape only); `0 < b < 1` grants a partial read.
|
|
3272
3262
|
const agentReadOpen = () => {
|
|
3273
3263
|
const b = policy.budgets?.agentRead;
|
|
3274
3264
|
return b == null || b > 0;
|
|
3275
3265
|
};
|
|
3266
|
+
const agentReadShare = () => {
|
|
3267
|
+
const b = policy.budgets?.agentRead;
|
|
3268
|
+
return b == null ? 1 : b;
|
|
3269
|
+
};
|
|
3270
|
+
// Which bodies a partial read admits. The selection is DETERMINISTIC, STABLE PER BODY ID, and
|
|
3271
|
+
// NOT POSITIONAL — each property is load-bearing, and the obvious implementations fail one:
|
|
3272
|
+
// · deterministic, because a random draw makes an agent read unreplayable, and record/replay
|
|
3273
|
+
// is a contract this engine keeps everywhere else (it threads a seeded `rng` for exactly this);
|
|
3274
|
+
// · stable, because a subset RESAMPLED each frame leaks the whole field to a patient reader —
|
|
3275
|
+
// the union of enough independent 10% samples is 100%. A budget that can be defeated by
|
|
3276
|
+
// calling `query()` in a loop is not a budget. This is the security core of the gate;
|
|
3277
|
+
// · not positional, because `take the first ceil(n·b)` leaks scan order, hands every agent the
|
|
3278
|
+
// same prefix, and makes the withheld tail identical for everyone.
|
|
3279
|
+
// FNV-1a over the body id gives a fixed [0,1) coordinate per body; admit those under the share.
|
|
3280
|
+
// The expected admitted count is n·b, not exactly n·b — that is inherent to a per-body stable
|
|
3281
|
+
// rule, and the alternative (an exact count) is necessarily positional or unstable.
|
|
3282
|
+
/**
|
|
3283
|
+
* Narrow a reading to the admitted share. Edges are filtered to SURVIVING ENDPOINTS: a
|
|
3284
|
+
* relationship or influence naming a withheld body is itself a disclosure of that body's id and
|
|
3285
|
+
* existence, so an edge survives only when every body it names does. Field-wide `metrics` are
|
|
3286
|
+
* aggregates over the whole field, not per-body readings, and are left to `read:metrics`.
|
|
3287
|
+
*/
|
|
3288
|
+
const sampleBodies = (res, share) => {
|
|
3289
|
+
if (!(share < 1))
|
|
3290
|
+
return res;
|
|
3291
|
+
const kept = new Set();
|
|
3292
|
+
for (const b of res.bodies)
|
|
3293
|
+
if (admitsShare(b.id, share))
|
|
3294
|
+
kept.add(b.id);
|
|
3295
|
+
res.bodies = res.bodies.filter((b) => kept.has(b.id));
|
|
3296
|
+
if (res.relationships)
|
|
3297
|
+
res.relationships = res.relationships.filter((r) => kept.has(r.from) && kept.has(r.to));
|
|
3298
|
+
if (res.influences) {
|
|
3299
|
+
res.influences = res.influences.filter((i) => kept.has(i.source) && (i.target == null || kept.has(i.target)));
|
|
3300
|
+
}
|
|
3301
|
+
return res;
|
|
3302
|
+
};
|
|
3276
3303
|
const scopeQuery = (q = {}) => {
|
|
3277
3304
|
if (!agentReadOpen()) {
|
|
3278
3305
|
// closed budget → most-restricted reading: shape only, no metrics/relationships/influences.
|
|
@@ -3305,6 +3332,9 @@ export function createField(canvas, opts = {}) {
|
|
|
3305
3332
|
res.influences = [];
|
|
3306
3333
|
if (!has('read:projections'))
|
|
3307
3334
|
res.projections = [];
|
|
3335
|
+
// #915: the fractional budget narrows the population AFTER capability scoping — tighten-only,
|
|
3336
|
+
// and it composes with the caps rather than replacing them.
|
|
3337
|
+
sampleBodies(res, agentReadShare());
|
|
3308
3338
|
return applyRedactions(res, redactions);
|
|
3309
3339
|
};
|
|
3310
3340
|
const scopeSnapshot = (snapOpts = {}) => {
|
|
@@ -3313,8 +3343,11 @@ export function createField(canvas, opts = {}) {
|
|
|
3313
3343
|
return applyRedactions(snap, redactions);
|
|
3314
3344
|
}
|
|
3315
3345
|
// read:body-data is the gate for opaque `data`: without it, force `includeData` off (tightens,
|
|
3316
|
-
// never widens — even if a profile or explicit flag asked for it).
|
|
3317
|
-
//
|
|
3346
|
+
// never widens — even if a profile or explicit flag asked for it). read:diagnostics is the gate
|
|
3347
|
+
// for the RAW PARTICLE POOL — the engine's own internal state, not a modelled reading of bodies /
|
|
3348
|
+
// relationships / metrics — so without it `includeParticles` is forced off even under `profile:
|
|
3349
|
+
// 'debug'`. Everything else composes with resolveSnapshotInclusion's TIGHTEST-wins rule + the
|
|
3350
|
+
// field's own privacy policy downstream.
|
|
3318
3351
|
const scoped = { ...snapOpts };
|
|
3319
3352
|
if (!has('read:body-data'))
|
|
3320
3353
|
scoped.includeData = false;
|
|
@@ -3322,17 +3355,33 @@ export function createField(canvas, opts = {}) {
|
|
|
3322
3355
|
scoped.includeRelationships = false;
|
|
3323
3356
|
if (!has('read:influences'))
|
|
3324
3357
|
scoped.includeInfluences = false;
|
|
3358
|
+
if (!has('read:diagnostics'))
|
|
3359
|
+
scoped.includeParticles = false;
|
|
3325
3360
|
const snap = handle.snapshot(scoped);
|
|
3326
3361
|
if (!has('read:projections'))
|
|
3327
3362
|
snap.projections = [];
|
|
3363
|
+
// #915: the same share applies to a capture, or `snapshot()` would hand back the whole field
|
|
3364
|
+
// that `query()` just withheld. The raw particle pool carries no body identity, so it has no
|
|
3365
|
+
// stable key to sample by; it stays gated by `read:diagnostics` alone (documented boundary).
|
|
3366
|
+
sampleBodies(snap, agentReadShare());
|
|
3328
3367
|
return applyRedactions(snap, redactions);
|
|
3329
3368
|
};
|
|
3330
3369
|
const view = {
|
|
3331
3370
|
get capabilities() { return Object.freeze([...caps]); },
|
|
3332
3371
|
get redactions() { return Object.freeze([...redactions]); },
|
|
3333
3372
|
query: scopeQuery,
|
|
3334
|
-
snapshot: scopeSnapshot,
|
|
3335
3373
|
};
|
|
3374
|
+
// `snapshot` is present ONLY when granted — the facade's shape reflects the grant, exactly as
|
|
3375
|
+
// `replay`/`focusState` do. read:snapshots gates the CAPTURE SURFACE, not a lane within a reading:
|
|
3376
|
+
// the per-lane caps (body-data / relationships / influences / projections / diagnostics) narrow what
|
|
3377
|
+
// a capture contains, this one decides whether a capture may be taken at all. Withholding it closes
|
|
3378
|
+
// the call rather than returning an empty shell — an empty capture is indistinguishable from an
|
|
3379
|
+
// empty field, and a silent-but-permissive-looking reading is the failure this gate exists to stop.
|
|
3380
|
+
// `query()` stays available under the base grant, so a denied agent is never blinded, only barred
|
|
3381
|
+
// from the portable, serializable, replayable artifact.
|
|
3382
|
+
if (has('read:snapshots')) {
|
|
3383
|
+
view.snapshot = scopeSnapshot;
|
|
3384
|
+
}
|
|
3336
3385
|
// `replay` is present ONLY when granted — the facade's shape reflects the capability.
|
|
3337
3386
|
if (has('read:replay')) {
|
|
3338
3387
|
view.replay = (a, b, replayOpts) => handle.replay(a, b, replayOpts);
|
|
@@ -3512,6 +3561,14 @@ export function createField(canvas, opts = {}) {
|
|
|
3512
3561
|
attrs['data-angle'] = String(spec.angle);
|
|
3513
3562
|
if (spec.color != null)
|
|
3514
3563
|
attrs['data-color'] = spec.color;
|
|
3564
|
+
// the declared-potential channel (#443) — the programmatic mirror of data-potential, so a
|
|
3565
|
+
// non-DOM host (a game, an agent runtime) can declare terrain-coupled matter through addBody.
|
|
3566
|
+
if (spec.potential != null)
|
|
3567
|
+
attrs['data-potential'] = spec.potential;
|
|
3568
|
+
// the capture horizon (#1177) — the programmatic mirror of data-absorb, so a non-DOM host can
|
|
3569
|
+
// size a sink's or a warp throat's radius without reaching past the public API.
|
|
3570
|
+
if (spec.absorbR != null)
|
|
3571
|
+
attrs['data-absorb'] = String(spec.absorbR);
|
|
3515
3572
|
const toRect = () => {
|
|
3516
3573
|
const r = spec.rect();
|
|
3517
3574
|
return {
|
|
@@ -3810,10 +3867,21 @@ export function createField(canvas, opts = {}) {
|
|
|
3810
3867
|
replay: (a, b, opts) => replayFieldSnapshots(a, b, opts),
|
|
3811
3868
|
addField: (name, sampler) => {
|
|
3812
3869
|
fieldChannels.set(name, sampler);
|
|
3870
|
+
invalidatePotentials(); // a channel registered AFTER a `relief` body was declared must couple
|
|
3813
3871
|
return {
|
|
3814
3872
|
name,
|
|
3815
|
-
set: (next) => {
|
|
3816
|
-
|
|
3873
|
+
set: (next) => {
|
|
3874
|
+
fieldChannels.set(name, next);
|
|
3875
|
+
invalidatePotentials(); // the swap is live: the next force pass reads the NEW surface
|
|
3876
|
+
},
|
|
3877
|
+
remove: () => {
|
|
3878
|
+
fieldChannels.delete(name);
|
|
3879
|
+
// Drop the raster with the channel. Without this the held cells would outlive the channel
|
|
3880
|
+
// and `relief` would keep transporting matter down a terrain the host has withdrawn.
|
|
3881
|
+
grids.delete(potentialKey(name));
|
|
3882
|
+
potentialStamp.delete(potentialKey(name));
|
|
3883
|
+
invalidatePotentials();
|
|
3884
|
+
},
|
|
3817
3885
|
};
|
|
3818
3886
|
},
|
|
3819
3887
|
sampleField: (name, x, y) => {
|