// fade.ts — TypeScript port of Plexum-sdk-go/fade. Deterministic fade- // out for kb-block / future subblock entries. Matches Plexum decision // #30 (n=5/w=10/m=70 default) so cross-runtime behaviour stays // identical when ports are compared. // // The algorithm: // - For elapsed `d <= n` turns since last_refresh, content unchanged // - For d > n: ticks = d - n; each tick masks ceil(w%) of the // currently-unmasked maskable-rune positions with `_`. Mask set is // cumulative across ticks. PRNG is seeded by (entrySeed ^ tick), // so the masking pattern is reproducible across processes. // - Tick drops the entry when underscore_ratio > m% of maskable. // // Maskable runes (matches Go source): // - ASCII alphanumerics [0-9A-Za-z] // - CJK ideographs (U+4E00–U+9FFF) // - Hiragana (U+3040–U+309F) + Katakana (U+30A0–U+30FF) // - Hangul syllables (U+AC00–U+D7A3) // Everything else (whitespace, punctuation, XML structure characters) // stays unmodified so the rendered XML keeps parseable shape. export interface FadeParams { n: number; // safe-period turns w: number; // % maskable chars masked per tick m: number; // % drop threshold on underscore_ratio } export interface FadeResult { rendered: string; maskedCount: number; totalMaskable: number; } export function defaultFadeParams(): FadeParams { return { n: 5, w: 10, m: 70 }; } /** shouldDrop returns true iff the entry's underscore ratio crosses m%. */ export function shouldDrop(r: FadeResult, params: FadeParams): boolean { if (r.totalMaskable === 0) return false; return r.maskedCount * 100 > params.m * r.totalMaskable; } /** * Fade computes the rendered form of `content` given the entry seed + * elapsed turns since last refresh. */ export function fade(content: string, seed: number | bigint, d: number, params: FadeParams): FadeResult { const runes = Array.from(content); // surrogate-pair safe const maskable: number[] = []; for (let i = 0; i < runes.length; i++) { if (isMaskable(runes[i])) maskable.push(i); } if (d <= params.n) { return { rendered: content, maskedCount: 0, totalMaskable: maskable.length }; } const ticks = d - params.n; const masked = positionsToMaskCumulative(maskable, BigInt(seed), ticks, params.w); return { rendered: applyUnderscores(runes, masked), maskedCount: masked.length, totalMaskable: maskable.length, }; } // ---- helpers ---- function isMaskable(s: string): boolean { if (s.length === 0) return false; // For surrogate pairs, take the first code point. const cp = s.codePointAt(0); if (cp === undefined) return false; if (cp >= 0x30 && cp <= 0x39) return true; // 0-9 if ((cp >= 0x41 && cp <= 0x5A) || (cp >= 0x61 && cp <= 0x7A)) return true; // A-Z a-z if (cp >= 0x4E00 && cp <= 0x9FFF) return true; // CJK Unified Ideographs if (cp >= 0x3040 && cp <= 0x309F) return true; // Hiragana if (cp >= 0x30A0 && cp <= 0x30FF) return true; // Katakana if (cp >= 0xAC00 && cp <= 0xD7A3) return true; // Hangul syllables return false; } function positionsToMaskCumulative(maskable: number[], seed: bigint, ticks: number, w: number): number[] { if (maskable.length === 0 || ticks <= 0 || w <= 0) return []; const used = new Set(); const result: number[] = []; for (let tick = 1; tick <= ticks; tick++) { const remaining = maskable.length - used.size; if (remaining === 0) break; let target = Math.ceil((w * remaining) / 100); if (target < 1) target = 1; if (target > remaining) target = remaining; const unmasked: number[] = []; for (const p of maskable) { if (!used.has(p)) unmasked.push(p); } // Deterministic shuffle using a tick-specific seed. const tickSeed = seed ^ BigInt(tick); shuffleDeterministic(unmasked, tickSeed); for (let i = 0; i < target; i++) { used.add(unmasked[i]); result.push(unmasked[i]); } } result.sort((a, b) => a - b); return result; } function applyUnderscores(runes: string[], masked: number[]): string { if (masked.length === 0) return runes.join(''); const maskSet = new Set(masked); let out = ''; for (let i = 0; i < runes.length; i++) { out += maskSet.has(i) ? '_' : runes[i]; } return out; } /** * Fisher-Yates shuffle using a Mulberry32-style xorshift PRNG seeded * by `seed`. Deterministic for the same seed input. * * Note: the Go source uses math/rand.NewSource(seed).Shuffle, which * uses a different PRNG algorithm. So the SAME (seed, tick) on Go vs * TS will produce different mask positions. That's acceptable for v1 * because no cross-runtime comparison of rendered fade is expected; * each runtime fades its own session-local kb-block independently. * If future use cases need cross-runtime parity, port Go's rand * algorithm to TS (~50 LOC). */ function shuffleDeterministic(arr: T[], seed: bigint): void { // Mulberry32 needs a u32 seed. let s = Number(seed & 0xFFFFFFFFn) >>> 0; const rng = () => { s = (s + 0x6D2B79F5) >>> 0; let t = s; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 0x100000000; }; for (let i = arr.length - 1; i > 0; i--) { const j = Math.floor(rng() * (i + 1)); [arr[i], arr[j]] = [arr[j], arr[i]]; } }