knosky 0.4.1 → 0.6.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.
@@ -0,0 +1,141 @@
1
+ // KnoSky ledger high-water-mark guard (SAT-443 / V13).
2
+ //
3
+ // Independently persists the highest-seen ledger sequence number in a
4
+ // dedicated file (separate from the ledger itself) so that a truncation
5
+ // attack that replaces the whole ledger cannot roll back the sequence.
6
+ //
7
+ // Anti-truncation rule (D-164 TUF-evolution): a ledger state whose sequence
8
+ // number is STRICTLY LOWER than the persisted high-water mark is refused.
9
+ //
10
+ // The HWM file is a minimal JSON document: { "ledger_hwm": <integer> }
11
+ // It is intentionally kept narrow — no ledger content lives here.
12
+ //
13
+ // Pure Node stdlib, ESM — no third-party dependencies.
14
+
15
+ import { readFileSync, writeFileSync, renameSync, mkdirSync, openSync, fsyncSync, closeSync } from 'node:fs';
16
+ import { dirname } from 'node:path';
17
+ import { MAX_PLAUSIBLE_LEDGER_SEQ } from './constants.mjs';
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // HWM file I/O
21
+ // ---------------------------------------------------------------------------
22
+
23
+ /**
24
+ * Read the persisted high-water mark from `hwmPath`.
25
+ * Returns 0 when the file does not exist (first run — any sequence is valid).
26
+ * Throws on I/O errors other than ENOENT, and on malformed content (fail-closed).
27
+ *
28
+ * @param {string} hwmPath Path to the independently-persisted HWM file.
29
+ * @returns {number} Stored sequence number (non-negative integer).
30
+ */
31
+ export function readHwm(hwmPath) {
32
+ let text;
33
+ try {
34
+ text = readFileSync(hwmPath, 'utf8');
35
+ } catch (err) {
36
+ if (err.code === 'ENOENT') return 0;
37
+ throw err; // permission error — fail-closed
38
+ }
39
+ let parsed;
40
+ try {
41
+ parsed = JSON.parse(text);
42
+ } catch {
43
+ throw new Error(`ledger HWM file is not valid JSON: ${hwmPath}`);
44
+ }
45
+ if (!Number.isInteger(parsed.ledger_hwm) || parsed.ledger_hwm < 0) {
46
+ throw new Error(
47
+ `ledger HWM file has invalid ledger_hwm: ${JSON.stringify(parsed.ledger_hwm)} in ${hwmPath}`,
48
+ );
49
+ }
50
+ return parsed.ledger_hwm;
51
+ }
52
+
53
+ /**
54
+ * Persist `seq` as the current high-water mark.
55
+ * Creates parent directories if they do not already exist.
56
+ *
57
+ * @param {string} hwmPath Path to write.
58
+ * @param {number} seq New HWM value (non-negative integer).
59
+ */
60
+ export function writeHwm(hwmPath, seq) {
61
+ if (!Number.isInteger(seq) || seq < 0) {
62
+ throw new TypeError(
63
+ `ledger_hwm must be a non-negative integer, got: ${JSON.stringify(seq)}`,
64
+ );
65
+ }
66
+ const dir = dirname(hwmPath);
67
+ mkdirSync(dir, { recursive: true });
68
+ // Atomic write: write to a sibling temp file, then rename into place.
69
+ // This prevents a partial/corrupt HWM file on crash or power loss —
70
+ // critical because a corrupt HWM would either silently reset the guard
71
+ // (ENOENT path) or throw (parse error), both of which weaken security.
72
+ const tmp = hwmPath + '.tmp';
73
+ writeFileSync(tmp, JSON.stringify({ ledger_hwm: seq }) + '\n', 'utf8');
74
+ // fsync the temp file's contents before rename — otherwise the rename can
75
+ // land on disk before the data it points to does (SAT-474 hardening review).
76
+ const tmpFd = openSync(tmp, 'r');
77
+ try { fsyncSync(tmpFd); } finally { closeSync(tmpFd); }
78
+ renameSync(tmp, hwmPath);
79
+ // fsync the containing directory so the rename itself (the directory-entry
80
+ // update) is durable — without this, a crash immediately after renameSync
81
+ // can leave the old HWM file name visible on some filesystems/mount options
82
+ // (e.g. ext4 without data=ordered). POSIX-specific guarantee; best-effort
83
+ // on platforms where directory fsync isn't supported.
84
+ try {
85
+ const dirFd = openSync(dir, 'r');
86
+ try { fsyncSync(dirFd); } finally { closeSync(dirFd); }
87
+ } catch { /* best-effort; not all platforms support directory fsync */ }
88
+ }
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // checkAndAdvance — the core anti-truncation guard
92
+ // ---------------------------------------------------------------------------
93
+
94
+ /**
95
+ * Enforce the high-water-mark invariant for incoming ledger state.
96
+ *
97
+ * Reads the persisted HWM from `hwmPath`, then applies the anti-truncation
98
+ * rule:
99
+ * - `seq` STRICTLY LESS than HWM → refuse. The HWM file is NOT updated.
100
+ * - `seq` EQUAL to HWM → accept (idempotent replay). HWM written.
101
+ * - `seq` GREATER than HWM → accept and advance. HWM written.
102
+ *
103
+ * Returning `{ ok: false }` means the caller must treat the incoming ledger
104
+ * state as invalid and stop processing — it must not be applied or surfaced
105
+ * as authoritative.
106
+ *
107
+ * @param {number} seq Claimed sequence number from the incoming ledger state.
108
+ * @param {string} hwmPath Path to the independently-persisted HWM file.
109
+ * @returns {{ ok: boolean, seq: number, hwm: number, error?: string }}
110
+ */
111
+ // Second layer of defense: enforced here too (not only in
112
+ // core/freshness.mjs's extractLedgerSeq) so checkAndAdvance is safe even if
113
+ // called directly. Imports the single source of truth from constants.mjs
114
+ // (a dependency-free module) so the two layers cannot silently diverge.
115
+ export function checkAndAdvance(seq, hwmPath) {
116
+ if (!Number.isInteger(seq) || seq < 0 || seq > MAX_PLAUSIBLE_LEDGER_SEQ) {
117
+ return {
118
+ ok: false,
119
+ seq,
120
+ hwm: -1,
121
+ error: `seq must be a non-negative integer no greater than ${MAX_PLAUSIBLE_LEDGER_SEQ}, got: ${JSON.stringify(seq)}`,
122
+ };
123
+ }
124
+
125
+ const hwm = readHwm(hwmPath);
126
+
127
+ if (seq < hwm) {
128
+ // Anti-truncation guard: incoming sequence is below the highest-seen mark.
129
+ // Do NOT update the file — the old (higher) mark must remain authoritative.
130
+ return {
131
+ ok: false,
132
+ seq,
133
+ hwm,
134
+ error: `ledger sequence ${seq} is below the high-water mark ${hwm} — refusing (anti-truncation guard)`,
135
+ };
136
+ }
137
+
138
+ // seq >= hwm: advance (or re-confirm) the mark.
139
+ writeHwm(hwmPath, seq);
140
+ return { ok: true, seq, hwm };
141
+ }
package/core/lod.mjs ADDED
@@ -0,0 +1,155 @@
1
+ // core/lod.mjs — Level-of-detail, viewport culling, clustering, and streaming
2
+ // utilities for large single-repo city graphs (SAT-447).
3
+ //
4
+ // All functions are pure (no side-effects, no DOM/canvas imports) so they can
5
+ // be used both in the Node.js test harness and inlined into the browser renderer.
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // LOD tier constants
9
+ // Each tier drives rendering fidelity in city.template.html.
10
+ // FULL — full-detail buildings, cars, trees, props, shadows, connections
11
+ // SIMPLE — buildings only (no cars / trees / props / shadows)
12
+ // DOT — each building renders as a coloured dot (very-far-out zoom)
13
+ // CLUSTER— entire district collapses to a single cluster badge
14
+ // ---------------------------------------------------------------------------
15
+ export const LOD_FULL = 0; // zoom >= LOD_FULL_THRESHOLD
16
+ export const LOD_SIMPLE = 1; // zoom >= LOD_SIMPLE_THRESHOLD
17
+ export const LOD_DOT = 2; // zoom >= LOD_DOT_THRESHOLD
18
+ export const LOD_CLUSTER = 3; // zoom < LOD_DOT_THRESHOLD
19
+
20
+ // Zoom boundary constants (cam.zoom scale, matching renderer defaults)
21
+ export const LOD_FULL_THRESHOLD = 0.35; // below → drop cars/trees/props/shadows
22
+ export const LOD_SIMPLE_THRESHOLD = 0.12; // below → dots only
23
+ export const LOD_DOT_THRESHOLD = 0.06; // below → cluster badges
24
+
25
+ /**
26
+ * Map a camera zoom value to an LOD tier.
27
+ * @param {number} zoom
28
+ * @returns {0|1|2|3}
29
+ */
30
+ export function lodForZoom(zoom) {
31
+ if (zoom >= LOD_FULL_THRESHOLD) return LOD_FULL;
32
+ if (zoom >= LOD_SIMPLE_THRESHOLD) return LOD_SIMPLE;
33
+ if (zoom >= LOD_DOT_THRESHOLD) return LOD_DOT;
34
+ return LOD_CLUSTER;
35
+ }
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Viewport culling
39
+ // Works in canvas/screen-space: test an iso-world point against a padded
40
+ // screen rectangle. Call once per draw to compute the clip region, then
41
+ // call isVisible() per object.
42
+ //
43
+ // cam = { x, y, zoom } (renderer camera object)
44
+ // cw = canvas CSS width
45
+ // ch = canvas CSS height
46
+ // pad = extra margin in world-units so objects at the edge aren't clipped
47
+ // (default: 2 tile-widths, 64px each → 128)
48
+ // ---------------------------------------------------------------------------
49
+
50
+ /**
51
+ * Compute an axis-aligned world-space clip rect for the current camera view.
52
+ * Returns { wx0, wy0, wx1, wy1 } in *world* coordinates (pre-camera transform).
53
+ * @param {{ x: number, y: number, zoom: number }} cam
54
+ * @param {number} cw canvas CSS width
55
+ * @param {number} ch canvas CSS height
56
+ * @param {number} [pad=128] extra padding around the edges in world units
57
+ * @returns {{ wx0: number, wy0: number, wx1: number, wy1: number }}
58
+ */
59
+ export function viewportClip(cam, cw, ch, pad = 128) {
60
+ // Screen corners → world coords: worldX = (screenX - cam.x) / cam.zoom
61
+ const wx0 = (0 - cam.x) / cam.zoom - pad;
62
+ const wy0 = (0 - cam.y) / cam.zoom - pad;
63
+ const wx1 = (cw - cam.x) / cam.zoom + pad;
64
+ const wy1 = (ch - cam.y) / cam.zoom + pad;
65
+ return { wx0, wy0, wx1, wy1 };
66
+ }
67
+
68
+ /**
69
+ * Return true if the iso-world point (wx, wy) is inside the clip rect.
70
+ * @param {number} wx
71
+ * @param {number} wy
72
+ * @param {{ wx0: number, wy0: number, wx1: number, wy1: number }} clip
73
+ * @returns {boolean}
74
+ */
75
+ export function isVisible(wx, wy, clip) {
76
+ return wx >= clip.wx0 && wx <= clip.wx1 && wy >= clip.wy0 && wy <= clip.wy1;
77
+ }
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // Clustering
81
+ // Group city nodes by their district and compute a single representative
82
+ // "cluster badge" position + count. The badge sits at the
83
+ // district centroid in world-space.
84
+ // ---------------------------------------------------------------------------
85
+
86
+ /**
87
+ * Build an array of cluster descriptors from the current NODES array.
88
+ * Each cluster has: { districtId, label, color, count, wx, wy }
89
+ *
90
+ * @param {Array<{ d: string, bx: number, by: number }>} nodes renderer-adapted nodes (with bx/by world pos)
91
+ * @param {{ [id: string]: { name: string, color: string } }} dcfg DCFG district config
92
+ * @returns {Array<{ districtId: string, label: string, color: string, count: number, wx: number, wy: number }>}
93
+ */
94
+ export function buildClusters(nodes, dcfg) {
95
+ const acc = {};
96
+ for (const n of nodes) {
97
+ const k = n.d;
98
+ if (!acc[k]) acc[k] = { sumX: 0, sumY: 0, count: 0 };
99
+ acc[k].sumX += n.bx;
100
+ acc[k].sumY += n.by;
101
+ acc[k].count++;
102
+ }
103
+ return Object.keys(acc).map(k => {
104
+ const a = acc[k];
105
+ const cfg = dcfg[k] || { name: k, color: '#7c83a3' };
106
+ return {
107
+ districtId: k,
108
+ label: cfg.name,
109
+ color: cfg.color,
110
+ count: a.count,
111
+ wx: a.sumX / a.count,
112
+ wy: a.sumY / a.count,
113
+ };
114
+ });
115
+ }
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // Streaming geometry build
119
+ // For large repos (node count >= STREAM_THRESHOLD) instead of building the
120
+ // entire OBJ array synchronously during ingest, the renderer schedules small
121
+ // batches of geometry across animation frames. This function splits a nodes
122
+ // array into fixed-size batches.
123
+ // ---------------------------------------------------------------------------
124
+
125
+ /** Minimum node count before streaming geometry build is preferred. */
126
+ export const STREAM_THRESHOLD = 400;
127
+
128
+ /** Nodes processed per animation-frame batch. */
129
+ export const STREAM_BATCH_SIZE = 80;
130
+
131
+ /**
132
+ * Split an array into batches of `size`.
133
+ * @template T
134
+ * @param {T[]} items
135
+ * @param {number} [size=STREAM_BATCH_SIZE]
136
+ * @returns {T[][]}
137
+ */
138
+ export function makeBatches(items, size = STREAM_BATCH_SIZE) {
139
+ if (!Array.isArray(items)) throw new TypeError('items must be an array');
140
+ if (!Number.isInteger(size) || size < 1) throw new RangeError('size must be a positive integer');
141
+ const batches = [];
142
+ for (let i = 0; i < items.length; i += size) {
143
+ batches.push(items.slice(i, i + size));
144
+ }
145
+ return batches;
146
+ }
147
+
148
+ /**
149
+ * Return true when the node count warrants streaming geometry build.
150
+ * @param {number} nodeCount
151
+ * @returns {boolean}
152
+ */
153
+ export function shouldStream(nodeCount) {
154
+ return nodeCount >= STREAM_THRESHOLD;
155
+ }