diff --git a/README.md b/README.md index 612e4e6..d826cc1 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,64 @@ A base scan (`src/lib/analysisEngine.js`) is enriched by `runLayerRouter` | **L46 — Firewall Receipt** | Deterministic content/result/soliton hashes for a reproducible audit trail. | | **L103 — 39 Hz Soliton Field** | Gamma-band synchrony + leapfrogging ionic-soliton model (below). | +### How good are the numbers? + +The scores used to be asserted. They are now measured against a labelled +corpus of 18 content archetypes (`src/lib/calibrationCorpus.js`), reported as +**rank agreement** rather than invented precision, and guarded in CI so a +scoring change cannot silently regress: + +| Dimension | Spearman ρ | Pairs ordered wrongly | +| --- | --- | --- | +| trust | 0.700 | 16 / 107 | +| urgency | 0.641 | 14 / 111 | +| manipulation risk | 0.631 | 21 / 117 | +| viral pull | 0.366 | 32 / 107 | + +**81% of 442 labelled comparisons ranked correctly** (mean ρ 0.585). + +Calibration found a real defect: trust was originally **anti-correlated** +(ρ −0.505), scoring outrage bait as more trustworthy than a sincere apology, +because it counted trust *vocabulary* rather than evidence. Adding specificity +and stated-limitation signals — and discounting specifics that sit inside +urgency phrasing, so a fake deadline cannot buy credibility — turned it +positive. + +Two honesty notes, both enforced by tests rather than left to good intentions: + +- The scores are **0–100 indices, not probabilities**. Each one is shown with + its percentile against the corpus, because "86th percentile of known + archetypes" is a true statement where "86%" is not. +- Labels are **ordinal**. We can defend that phishing carries more pressure + than an understated luxury line; we cannot defend a claim that it scores + exactly 84. See `docs/ANNOTATION_RUBRIC.md`, which also requires a + Krippendorff's alpha (`src/lib/agreement.js`) before any corpus release + claims reliability. + +### The brain, and a real spiking network + +The 7-region model (`src/features/brain3d/brainModel.js`) is deterministic and +seeded, so the same content always produces the same run — which is what makes +brain readouts scoreable, shareable and verifiable. It exposes interventions +(lesion a region, cut a pathway, inject current) and derived measurements: +firing rates in Hz, PFC/AMY control ratio, hijack index, gain around the +THL→CTX→AMY→BG⊣THL control loop, E/I balance, spike correlation, settling +time, plasticity and net STDP flux. + +That model is a *rate* model. `src/lib/snn/` adds the real thing: a leaky +integrate-and-fire network in the Brunel (2000) formulation — Dale's law, +sparse random connectivity, transmission delays, Poisson drive — with the +standard measurements (CV of ISI, Fano factor, population spectrum). Its +regime behaviour is checked against the published analysis in +`brunelValidation.test.js`, so **gamma-band power is measured rather than +asserted**. + +**Claim boundary.** Every neural readout ships with it: these are simulated +dynamics of a model driven by lexical features of the text, not a measurement +of any human brain, and not a clinical or predictive claim. The simulation is +downstream of the same lexical scores, so it adds structure — not new +information about the text. + The full catalog of 103 layers lives in `src/lib/layerCatalog.js`; the Research view has a searchable Layer Explorer. diff --git a/brainsnn-r3f-app/.gitignore b/brainsnn-r3f-app/.gitignore index 5a86d2a..c0164f7 100644 --- a/brainsnn-r3f-app/.gitignore +++ b/brainsnn-r3f-app/.gitignore @@ -6,3 +6,6 @@ coverage/ *.log .env* !.env.example + +# Externally licensed evaluation corpora are fetched locally, never vendored. +datasets/ diff --git a/brainsnn-r3f-app/scripts/eval-corpus.mjs b/brainsnn-r3f-app/scripts/eval-corpus.mjs new file mode 100644 index 0000000..8f7c947 --- /dev/null +++ b/brainsnn-r3f-app/scripts/eval-corpus.mjs @@ -0,0 +1,83 @@ +#!/usr/bin/env node +// Evaluate the scoring engine against an externally labelled corpus. +// +// node scripts/eval-corpus.mjs datasets/persuasion.jsonl --dimension manipulationRisk +// +// Input is JSONL, one object per line: +// { "id": "...", "text": "...", "labels": { "manipulationRisk": 1, "trust": 0 } } +// where each label is 0/1 (or true/false). +// +// Public corpora are NOT vendored into this repo — they carry their own +// licences. Fetch them into datasets/ (gitignored) and point this script at +// the converted JSONL. See docs/ANNOTATION_RUBRIC.md for the mapping from +// published technique taxonomies onto our dimensions. +import { readFileSync } from 'node:fs'; +import { pathToFileURL } from 'node:url'; + +// The engine reads a localStorage shim in a couple of places. +globalThis.window = globalThis.window || { + localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {}, clear: () => {} }, +}; +globalThis.localStorage = globalThis.window.localStorage; + +const [, , file, ...rest] = process.argv; +if (!file) { + console.error('usage: node scripts/eval-corpus.mjs [--dimension ] [--bins ]'); + process.exit(2); +} + +function flag(name, fallback) { + const index = rest.indexOf(`--${name}`); + return index >= 0 && rest[index + 1] ? rest[index + 1] : fallback; +} + +const base = pathToFileURL(`${process.cwd()}/`); +const { scoreCorpusItem } = await import(new URL('src/lib/calibration.js', base)); +const { evaluateBinary } = await import(new URL('src/lib/evalMetrics.js', base)); + +const rows = readFileSync(file, 'utf8') + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .map((line, index) => { + try { + return JSON.parse(line); + } catch (error) { + console.error(`skipping unparseable line ${index + 1}: ${error.message}`); + return null; + } + }) + .filter((row) => row && typeof row.text === 'string' && row.labels); + +if (!rows.length) { + console.error('no usable rows found'); + process.exit(1); +} + +const requested = flag('dimension', null); +const dimensions = requested + ? [requested] + : [...new Set(rows.flatMap((row) => Object.keys(row.labels)))]; + +const scored = rows.map((row) => ({ row, metrics: scoreCorpusItem({ content: row.text }) })); + +const report = { corpus: file, items: rows.length, dimensions: {} }; +for (const dimension of dimensions) { + const usable = scored.filter(({ row }) => row.labels[dimension] != null); + if (usable.length < 4) { + report.dimensions[dimension] = { skipped: `only ${usable.length} labelled items` }; + continue; + } + const scores = usable.map(({ metrics }) => metrics[dimension] ?? 0); + const labels = usable.map(({ row }) => (row.labels[dimension] ? 1 : 0)); + const result = evaluateBinary(scores, labels, { bins: Number(flag('bins', 10)) }); + // Keep the console summary readable; the table is in the JSON. + report.dimensions[dimension] = result; + console.log( + `${dimension.padEnd(20)} n=${String(result.n).padStart(5)} pos=${String(result.positives).padStart(5)} ` + + `AUC=${result.auc.toFixed(3)} Brier=${result.brier.toFixed(3)} ECE=${result.ece.toFixed(3)} ` + + `bestF1=${result.bestF1.f1.toFixed(3)}@${result.bestF1.threshold}`, + ); +} + +if (rest.includes('--json')) console.log(JSON.stringify(report, null, 2)); diff --git a/brainsnn-r3f-app/src/app/GaugeGapLanding.jsx b/brainsnn-r3f-app/src/app/GaugeGapLanding.jsx index f283f25..0adb3f6 100644 --- a/brainsnn-r3f-app/src/app/GaugeGapLanding.jsx +++ b/brainsnn-r3f-app/src/app/GaugeGapLanding.jsx @@ -147,7 +147,7 @@ export function GaugeGapLanding({ onStart, onNavigate, onOpenReconstruct }) {
-
13 live12 arcade + flagship lab
+
16 live15 arcade + flagship lab
4 pathsClear first steps
ClientCustom pilot pathway
OpenModels and limits
@@ -176,7 +176,7 @@ export function GaugeGapLanding({ onStart, onNavigate, onOpenReconstruct }) { - +

The engagement engine

A useful interaction should continue after the first click.

The visual earns attention. The model earns understanding. The shared state brings the next person into the same system.

@@ -195,7 +195,7 @@ export function GaugeGapLanding({ onStart, onNavigate, onOpenReconstruct }) {
diff --git a/brainsnn-r3f-app/src/features/brain3d/brainMetrics.js b/brainsnn-r3f-app/src/features/brain3d/brainMetrics.js new file mode 100644 index 0000000..2772b84 --- /dev/null +++ b/brainsnn-r3f-app/src/features/brain3d/brainMetrics.js @@ -0,0 +1,165 @@ +// Derived measurements over a brain trial. +// +// Every quantity here is a deterministic function of the trace produced by +// runBrainTrial, so the same content and seed always yield the same numbers. +// +// CLAIM BOUNDARY: these describe the dynamics of a 7-region model driven by +// lexical features of the input text. They are not measurements of any human +// brain, and carry no clinical or predictive meaning. +import { BRAIN_REGIONS, PATHWAYS } from './brainRegions.js'; + +// The one closed loop in the graph: attention -> meaning -> salience -> action +// gate -> (inhibits) attention. Its gain is the model's runaway-vs-controlled +// quantity, and the reason the game has a fail state. +export const CONTROL_LOOP = ['THL-CTX', 'CTX-AMY', 'AMY-BG', 'BG-THL']; + +function mean(values) { + if (!values.length) return 0; + return values.reduce((sum, value) => sum + value, 0) / values.length; +} + +function clamp01(value) { + return Math.max(0, Math.min(1, value)); +} + +function round(value, places = 4) { + const factor = 10 ** places; + return Math.round((Number(value) || 0) * factor) / factor; +} + +// Pearson correlation; returns 0 when either train is constant (undefined r). +function correlation(a, b) { + const n = Math.min(a.length, b.length); + if (n < 2) return 0; + const meanA = mean(a); + const meanB = mean(b); + let num = 0; + let devA = 0; + let devB = 0; + for (let i = 0; i < n; i += 1) { + const da = a[i] - meanA; + const db = b[i] - meanB; + num += da * db; + devA += da * da; + devB += db * db; + } + if (devA <= 0 || devB <= 0) return 0; + return num / Math.sqrt(devA * devB); +} + +function activitySeries(trace, code) { + return trace.map((frame) => frame.activities[code] ?? 0); +} + +function spikeSeries(trace, code) { + return trace.map((frame) => (frame.spikes[code] ? 1 : 0)); +} + +export function computeBrainMetrics(trial) { + const { trace = [], finalState, params, targets } = trial || {}; + if (!trace.length || !finalState) return null; + + const seconds = (trace.length * params.tickMs) / 1000; + const codes = BRAIN_REGIONS.map((region) => region.code); + + const firingRateHz = {}; + const meanActivity = {}; + const eiBalance = {}; + for (const code of codes) { + const spikes = spikeSeries(trace, code); + firingRateHz[code] = round(spikes.reduce((sum, value) => sum + value, 0) / seconds, 3); + meanActivity[code] = round(mean(activitySeries(trace, code)), 4); + const excitatory = mean(trace.map((frame) => frame.drive[code]?.excitatory ?? 0)); + const inhibitory = mean(trace.map((frame) => frame.drive[code]?.inhibitory ?? 0)); + const total = excitatory + inhibitory; + eiBalance[code] = round(total > 0 ? (excitatory - inhibitory) / total : 0, 4); + } + + // Judgment vs threat: the model's core semantic contrast. Capped because a + // silenced amygdala drives the denominator to ~0, and any ratio past 10 says + // the same thing: threat is not competing. + const CONTROL_RATIO_CAP = 10; + const pfc = meanActivity.PFC; + const amy = meanActivity.AMY; + const controlRatio = round( + amy > 0 ? Math.min(CONTROL_RATIO_CAP, pfc / amy) : pfc > 0 ? CONTROL_RATIO_CAP : 1, + 4, + ); + + // How far threat + action pressure ran ahead of reflective control, 0-100. + const hijackRaw = mean(trace.map((frame) => ( + ((frame.activities.AMY ?? 0) + (frame.activities.BG ?? 0)) / 2 - (frame.activities.PFC ?? 0) + ))); + const hijackIndex = Math.round(clamp01(hijackRaw + 0.5) * 100); + + const finalWeights = finalState.weights; + const loopGain = round(CONTROL_LOOP.reduce((product, id) => product * (finalWeights[id] ?? 0), 1), 6); + const gateIntegrity = round((finalWeights['BG-THL'] ?? 0) * meanActivity.BG, 4); + + // Mean pairwise correlation of the region spike trains. + const trains = codes.map((code) => spikeSeries(trace, code)); + const pairs = []; + for (let i = 0; i < trains.length; i += 1) { + for (let j = i + 1; j < trains.length; j += 1) pairs.push(correlation(trains[i], trains[j])); + } + const synchronyIndex = round(mean(pairs), 4); + + // Time to reach the driven equilibrium, in ticks (null if it never settles). + let settlingTicks = null; + if (targets) { + const tolerance = 0.08 * codes.length; + let consecutive = 0; + for (let index = 0; index < trace.length; index += 1) { + const distance = codes.reduce((sum, code) => ( + targets[code] == null ? sum : sum + Math.abs((trace[index].activities[code] ?? 0) - targets[code]) + ), 0); + consecutive = distance < tolerance ? consecutive + 1 : 0; + if (consecutive >= 5) { + settlingTicks = index - 4; + break; + } + } + } + + const weightDrift = round(PATHWAYS.reduce((sum, pathway) => ( + sum + Math.abs((finalWeights[pathway.id] ?? 0) - pathway.initialWeight) + ), 0), 4); + + const stdpFlux = round(trace.reduce((sum, frame) => ( + sum + Object.values(frame.stdpDelta || {}).reduce((inner, value) => inner + value, 0) + ), 0), 4); + + return { + seconds: round(seconds, 3), + ticks: trace.length, + firingRateHz, + meanActivity, + eiBalance, + controlRatio, + hijackIndex, + loopGain, + gateIntegrity, + synchronyIndex, + settlingTicks, + plasticity: round(finalState.plasticity, 4), + weightDrift, + stdpFlux, + meanFiring: round(mean(trace.map((frame) => frame.meanFiring)), 4), + }; +} + +// Presentation metadata: label, units and which direction is "healthy", so the +// UI never has to hardcode interpretation. +export const METRIC_DESCRIPTORS = Object.freeze([ + { id: 'controlRatio', label: 'Control ratio', unit: 'PFC ÷ AMY', direction: 'higher-good', explanation: 'Reflective control relative to threat response.' }, + { id: 'hijackIndex', label: 'Hijack index', unit: '0–100', direction: 'lower-good', explanation: 'How far threat and action pressure ran ahead of judgment.' }, + { id: 'loopGain', label: 'Loop gain', unit: 'product of 4 weights', direction: 'lower-good', explanation: 'Gain around the attention → salience → gate loop.' }, + { id: 'gateIntegrity', label: 'Gate integrity', unit: '0–1', direction: 'higher-good', explanation: 'Strength of the inhibitory brake on incoming attention.' }, + { id: 'synchronyIndex', label: 'Synchrony', unit: 'mean pairwise r', direction: 'contextual', explanation: 'Correlation between region spike trains.' }, + { id: 'plasticity', label: 'Plasticity', unit: 'mean weight', direction: 'contextual', explanation: 'Average synaptic strength after learning.' }, + { id: 'weightDrift', label: 'Weight drift', unit: 'Σ|Δw|', direction: 'contextual', explanation: 'Total learning away from the initial wiring.' }, + { id: 'stdpFlux', label: 'STDP flux', unit: 'Σ potentiation − depression', direction: 'contextual', explanation: 'Net direction of spike-timing learning.' }, +]); + +export const BRAIN_CLAIM_BOUNDARY = 'Simulated dynamics of a 7-region model driven by lexical features of the text. ' + + 'Not a measurement of any human brain, and not a clinical or predictive claim.'; diff --git a/brainsnn-r3f-app/src/features/brain3d/brainMetrics.test.js b/brainsnn-r3f-app/src/features/brain3d/brainMetrics.test.js new file mode 100644 index 0000000..579814a --- /dev/null +++ b/brainsnn-r3f-app/src/features/brain3d/brainMetrics.test.js @@ -0,0 +1,94 @@ +import { describe, expect, it } from '../../test/tinyVitest.js'; +import { runBrainTrial } from './brainModel.js'; +import { computeBrainMetrics, CONTROL_LOOP, METRIC_DESCRIPTORS } from './brainMetrics.js'; + +const PRESSURE_TARGETS = { THL: 0.8, CTX: 0.4, HPC: 0.3, PFC: 0.2, AMY: 0.85, BG: 0.8, CBL: 0.3 }; +const CALM_TARGETS = { THL: 0.45, CTX: 0.6, HPC: 0.5, PFC: 0.8, AMY: 0.15, BG: 0.2, CBL: 0.4 }; + +function metricsFor(options) { + return computeBrainMetrics(runBrainTrial({ seed: 'metrics', ticks: 180, ...options })); +} + +describe('computeBrainMetrics', () => { + it('returns null for an empty trial', () => { + expect(computeBrainMetrics(null)).toBe(null); + expect(computeBrainMetrics({ trace: [], finalState: null })).toBe(null); + }); + + it('is deterministic for a given seed', () => { + const a = metricsFor({ targets: PRESSURE_TARGETS }); + const b = metricsFor({ targets: PRESSURE_TARGETS }); + expect(JSON.stringify(a)).toBe(JSON.stringify(b)); + }); + + it('reports firing rates in Hz consistent with the tick rate', () => { + const metrics = metricsFor({ targets: PRESSURE_TARGETS }); + expect(metrics.seconds).toBeGreaterThan(0); + for (const rate of Object.values(metrics.firingRateHz)) { + expect(rate).toBeGreaterThanOrEqual(0); + // Ceiling is the tick rate itself: at most one spike per 120 ms tick. + expect(rate).toBeLessThanOrEqual(1000 / 120); + } + }); + + it('separates high-pressure content from calm content', () => { + const pressure = metricsFor({ targets: PRESSURE_TARGETS }); + const calm = metricsFor({ targets: CALM_TARGETS }); + expect(pressure.hijackIndex).toBeGreaterThan(calm.hijackIndex); + expect(calm.controlRatio).toBeGreaterThan(pressure.controlRatio); + }); + + it('drops the hijack index when the threat region is lesioned', () => { + const intact = metricsFor({ targets: PRESSURE_TARGETS }); + const lesioned = metricsFor({ + targets: PRESSURE_TARGETS, + interventions: { lesions: ['AMY'], cuts: [], stimuli: {} }, + }); + expect(lesioned.hijackIndex).toBeLessThan(intact.hijackIndex); + }); + + it('loses the inhibitory brake when the gate pathway is cut', () => { + const cut = metricsFor({ + targets: PRESSURE_TARGETS, + interventions: { lesions: [], cuts: ['BG-THL'], stimuli: {} }, + }); + // With BG-THL cut, THL receives no inhibition at all. + expect(cut.eiBalance.THL).toBe(0); + }); + + it('keeps bounded metrics inside their ranges', () => { + const metrics = metricsFor({ targets: PRESSURE_TARGETS }); + expect(metrics.hijackIndex).toBeGreaterThanOrEqual(0); + expect(metrics.hijackIndex).toBeLessThanOrEqual(100); + expect(metrics.synchronyIndex).toBeGreaterThanOrEqual(-1); + expect(metrics.synchronyIndex).toBeLessThanOrEqual(1); + for (const balance of Object.values(metrics.eiBalance)) { + expect(balance).toBeGreaterThanOrEqual(-1); + expect(balance).toBeLessThanOrEqual(1); + } + }); + + it('computes loop gain from the four control-loop weights', () => { + const trial = runBrainTrial({ targets: PRESSURE_TARGETS, seed: 'loop', ticks: 90 }); + const metrics = computeBrainMetrics(trial); + const expected = CONTROL_LOOP.reduce((product, id) => product * trial.finalState.weights[id], 1); + expect(Math.abs(metrics.loopGain - expected)).toBeLessThan(1e-6); + }); + + it('reports settling time only when targets are supplied', () => { + expect(metricsFor({}).settlingTicks).toBe(null); + const settled = metricsFor({ targets: CALM_TARGETS }).settlingTicks; + expect(settled === null || settled >= 0).toBe(true); + }); +}); + +describe('METRIC_DESCRIPTORS', () => { + it('describes each headline metric with a direction', () => { + const metrics = metricsFor({ targets: PRESSURE_TARGETS }); + for (const descriptor of METRIC_DESCRIPTORS) { + expect(typeof descriptor.label).toBe('string'); + expect(['higher-good', 'lower-good', 'contextual'].includes(descriptor.direction)).toBe(true); + expect(metrics[descriptor.id] !== undefined).toBe(true); + } + }); +}); diff --git a/brainsnn-r3f-app/src/features/brain3d/brainModel.js b/brainsnn-r3f-app/src/features/brain3d/brainModel.js new file mode 100644 index 0000000..d899517 --- /dev/null +++ b/brainsnn-r3f-app/src/features/brain3d/brainModel.js @@ -0,0 +1,260 @@ +// Pure, seeded, parameterized brain model. +// +// The dynamics are the ones the 3D brain has always run — a 7-region leaky rate +// network with STDP — lifted out of the React hook so they can be: +// * seeded (same content -> same run, which the old Math.random() prevented), +// * parameterized (every constant was a hardcoded literal), +// * intervened on (lesion a region, cut a pathway, inject current), +// * run headless for scoring, verification and tests. +// +// Nothing here touches React, the DOM or three.js. DEFAULT_PARAMS reproduces +// the original behaviour exactly, so the live visual is unchanged. +import { BRAIN_REGIONS, PATHWAYS, REGION_MAP } from './brainRegions.js'; +import { createRng } from '../../lib/rng.js'; + +export const DEFAULT_PARAMS = Object.freeze({ + // Membrane / rate dynamics + leak: 0.78, + synapticGain: 0.18, + homeostasisRate: 0.12, + targetBlend: 0.7, + baseBlend: 0.3, + replayRate: 0.022, + noiseAmplitude: 0.018, + activityFloor: 0.03, + activityCeiling: 1, + // Stimulus + burstGain: 0.018, + burstTicks: 18, + // Spiking + spikeThreshold: 0.24, + spikeSlope: 1.1, + spikeMax: 0.85, + // STDP + stdpPotentiation: 0.014, + stdpDepression: 0.013, + stdpTau: 3.2, + stdpWindow: 5, + burstPotentiation: 0.0035, + // Weight regulation + weightSetPoint: 0.44, + weightHomeostasisRate: 0.015, + hebbianRate: 0.0065, + inhibitionDrag: 0.001, + weightFloor: 0.08, + weightCeiling: 0.95, + // Timing + tickMs: 120, + historyLength: 48, +}); + +export const EMPTY_INTERVENTIONS = Object.freeze({ lesions: [], cuts: [], stimuli: {} }); + +function clamp(value, min, max) { + return Math.max(min, Math.min(max, value)); +} + +function mean(values) { + if (!values.length) return 0; + return values.reduce((sum, value) => sum + value, 0) / values.length; +} + +export function createBrainParams(overrides = {}) { + return { ...DEFAULT_PARAMS, ...(overrides || {}) }; +} + +function normalizeInterventions(interventions) { + const source = interventions || EMPTY_INTERVENTIONS; + return { + lesions: new Set(source.lesions || []), + cuts: new Set(source.cuts || []), + stimuli: source.stimuli || {}, + }; +} + +export function createBrainState(params = DEFAULT_PARAMS) { + const activities = Object.fromEntries(BRAIN_REGIONS.map((region) => [region.code, region.baseActivity])); + const weights = Object.fromEntries(PATHWAYS.map((pathway) => [pathway.id, pathway.initialWeight])); + return { + tick: 0, + burstFrames: 0, + selectedRegion: null, + activities, + weights, + lastSpike: Object.fromEntries(BRAIN_REGIONS.map((region) => [region.code, -999])), + spikes: Object.fromEntries(BRAIN_REGIONS.map((region) => [region.code, false])), + meanFiring: mean(Object.values(activities)), + plasticity: mean(Object.values(weights)), + // Per-tick diagnostics that the original loop computed and discarded. + drive: Object.fromEntries(BRAIN_REGIONS.map((region) => [region.code, { excitatory: 0, inhibitory: 0 }])), + stdpDelta: Object.fromEntries(PATHWAYS.map((pathway) => [pathway.id, 0])), + history: Array.from({ length: 32 }, () => mean(Object.values(activities))), + }; +} + +// Splits incoming drive into its excitatory and inhibitory parts instead of +// collapsing them, so excitation/inhibition balance becomes measurable. +function computeDrive(regionCode, activities, weights, lesions, cuts) { + let excitatory = 0; + let inhibitory = 0; + for (const pathway of PATHWAYS) { + if (pathway.to !== regionCode) continue; + if (cuts.has(pathway.id) || lesions.has(pathway.from)) continue; + const contribution = activities[pathway.from] * weights[pathway.id]; + if (pathway.inhibitory) inhibitory += contribution; + else excitatory += contribution; + } + return { excitatory, inhibitory }; +} + +export function spikeProbability(activity, params = DEFAULT_PARAMS) { + return clamp((activity - params.spikeThreshold) * params.spikeSlope, 0, params.spikeMax); +} + +export function stepBrain(previous, { targets = null, params = DEFAULT_PARAMS, interventions = EMPTY_INTERVENTIONS, rng = Math.random } = {}) { + const { lesions, cuts, stimuli } = normalizeInterventions(interventions); + const nextTick = previous.tick + 1; + const burstFrames = Math.max(0, previous.burstFrames - 1); + + const nextActivities = {}; + const nextDrive = {}; + const nextLastSpike = { ...previous.lastSpike }; + const spiked = {}; + + for (const region of BRAIN_REGIONS) { + const code = region.code; + const drive = computeDrive(code, previous.activities, previous.weights, lesions, cuts); + nextDrive[code] = drive; + + if (lesions.has(code)) { + // A lesioned region is silent and cannot spike; it still occupies the + // graph so downstream regions lose their input rather than rewiring. + nextActivities[code] = 0; + spiked[code] = false; + continue; + } + + const incoming = drive.excitatory - drive.inhibitory; + const thalamicBurst = code === 'THL' ? burstFrames * params.burstGain : 0; + const injected = Number(stimuli[code]) || 0; + // Blend the anatomical base with the externally-driven target so scan data + // steers the equilibrium without freezing the dynamics. + const target = targets?.[code] != null + ? region.baseActivity * params.baseBlend + targets[code] * params.targetBlend + : region.baseActivity; + const homeostasis = (target - previous.activities[code]) * params.homeostasisRate; + const replayBoost = code === 'HPC' || code === 'CTX' + ? (previous.activities.HPC + previous.activities.CTX) * params.replayRate + : 0; + const noise = (rng() * 2 - 1) * params.noiseAmplitude; + + const nextActivity = clamp( + previous.activities[code] * params.leak + + incoming * params.synapticGain + + homeostasis + + replayBoost + + thalamicBurst + + injected + + noise, + params.activityFloor, + params.activityCeiling, + ); + + nextActivities[code] = nextActivity; + + if (rng() < spikeProbability(nextActivity, params)) { + nextLastSpike[code] = nextTick; + spiked[code] = true; + } else { + spiked[code] = false; + } + } + + const nextWeights = {}; + const nextStdpDelta = {}; + for (const pathway of PATHWAYS) { + if (cuts.has(pathway.id)) { + // A cut pathway is frozen, not deleted: reconnecting restores its weight. + nextWeights[pathway.id] = previous.weights[pathway.id]; + nextStdpDelta[pathway.id] = 0; + continue; + } + + const dt = nextLastSpike[pathway.to] - nextLastSpike[pathway.from]; + let delta = 0; + + if (Math.abs(dt) <= params.stdpWindow) { + if (dt > 0) delta += Math.exp(-Math.abs(dt) / params.stdpTau) * params.stdpPotentiation; + else if (dt < 0) delta -= Math.exp(-Math.abs(dt) / params.stdpTau) * params.stdpDepression; + } + + if (burstFrames > 0 && pathway.from === 'THL') delta += params.burstPotentiation; + + const homeostaticPull = (params.weightSetPoint - previous.weights[pathway.id]) * params.weightHomeostasisRate; + const activityCoupling = nextActivities[pathway.from] * nextActivities[pathway.to] * params.hebbianRate; + const drag = pathway.inhibitory ? -params.inhibitionDrag : 0; + + nextStdpDelta[pathway.id] = delta; + nextWeights[pathway.id] = clamp( + previous.weights[pathway.id] + delta + homeostaticPull + activityCoupling + drag, + params.weightFloor, + params.weightCeiling, + ); + } + + const meanFiring = mean(Object.values(nextActivities)); + + return { + ...previous, + tick: nextTick, + burstFrames, + activities: nextActivities, + weights: nextWeights, + lastSpike: nextLastSpike, + spikes: spiked, + drive: nextDrive, + stdpDelta: nextStdpDelta, + meanFiring, + plasticity: mean(Object.values(nextWeights)), + history: [...previous.history, meanFiring].slice(-params.historyLength), + }; +} + +/** + * Headless deterministic run. Same seed + inputs always produce the same trace, + * which is what makes brain readouts scoreable, shareable and verifiable. + */ +export function runBrainTrial({ + targets = null, + params: paramOverrides = null, + interventions = EMPTY_INTERVENTIONS, + seed = 'brainsnn', + ticks = 240, + settleTicks = 0, +} = {}) { + const params = createBrainParams(paramOverrides); + const rng = createRng(seed); + let state = createBrainState(params); + if (targets) state = { ...state, burstFrames: params.burstTicks }; + + const trace = []; + const totalTicks = Math.max(1, Math.floor(ticks) + Math.floor(settleTicks)); + for (let index = 0; index < totalTicks; index += 1) { + state = stepBrain(state, { targets, params, interventions, rng }); + if (index >= settleTicks) { + trace.push({ + tick: state.tick, + activities: state.activities, + spikes: state.spikes, + weights: state.weights, + drive: state.drive, + stdpDelta: state.stdpDelta, + meanFiring: state.meanFiring, + }); + } + } + + return { trace, finalState: state, params, targets, interventions, seed, ticks: totalTicks }; +} + +export { BRAIN_REGIONS, PATHWAYS, REGION_MAP }; diff --git a/brainsnn-r3f-app/src/features/brain3d/brainModel.test.js b/brainsnn-r3f-app/src/features/brain3d/brainModel.test.js new file mode 100644 index 0000000..6716f52 --- /dev/null +++ b/brainsnn-r3f-app/src/features/brain3d/brainModel.test.js @@ -0,0 +1,145 @@ +import { describe, expect, it } from '../../test/tinyVitest.js'; +import { BRAIN_REGIONS, PATHWAYS } from './brainRegions.js'; +import { + createBrainParams, + createBrainState, + DEFAULT_PARAMS, + runBrainTrial, + spikeProbability, + stepBrain, +} from './brainModel.js'; +import { createRng } from '../../lib/rng.js'; + +const PRESSURE_TARGETS = { THL: 0.8, CTX: 0.4, HPC: 0.3, PFC: 0.2, AMY: 0.85, BG: 0.8, CBL: 0.3 }; +const CALM_TARGETS = { THL: 0.45, CTX: 0.6, HPC: 0.5, PFC: 0.8, AMY: 0.15, BG: 0.2, CBL: 0.4 }; + +describe('stepBrain', () => { + it('is deterministic for a given seed', () => { + const a = runBrainTrial({ targets: PRESSURE_TARGETS, seed: 'seed-a', ticks: 60 }); + const b = runBrainTrial({ targets: PRESSURE_TARGETS, seed: 'seed-a', ticks: 60 }); + expect(JSON.stringify(a.trace)).toBe(JSON.stringify(b.trace)); + }); + + it('produces a different trace for a different seed', () => { + const a = runBrainTrial({ targets: PRESSURE_TARGETS, seed: 'seed-a', ticks: 60 }); + const b = runBrainTrial({ targets: PRESSURE_TARGETS, seed: 'seed-b', ticks: 60 }); + expect(JSON.stringify(a.trace) === JSON.stringify(b.trace)).toBe(false); + }); + + it('keeps activities and weights inside their configured bounds', () => { + const params = createBrainParams(); + const { trace } = runBrainTrial({ targets: PRESSURE_TARGETS, seed: 'bounds', ticks: 120 }); + for (const frame of trace) { + for (const region of BRAIN_REGIONS) { + expect(frame.activities[region.code]).toBeGreaterThanOrEqual(0); + expect(frame.activities[region.code]).toBeLessThanOrEqual(params.activityCeiling); + } + for (const pathway of PATHWAYS) { + expect(frame.weights[pathway.id]).toBeGreaterThanOrEqual(params.weightFloor); + expect(frame.weights[pathway.id]).toBeLessThanOrEqual(params.weightCeiling); + } + } + }); + + it('stays finite under extreme parameters', () => { + const { trace } = runBrainTrial({ + targets: PRESSURE_TARGETS, + params: { synapticGain: 3, hebbianRate: 0.5, noiseAmplitude: 0.4, leak: 0.99 }, + seed: 'extreme', + ticks: 80, + }); + for (const frame of trace) { + for (const value of Object.values(frame.activities)) expect(Number.isFinite(value)).toBe(true); + for (const value of Object.values(frame.weights)) expect(Number.isFinite(value)).toBe(true); + } + }); +}); + +describe('interventions', () => { + it('silences a lesioned region and starves its targets', () => { + const lesioned = runBrainTrial({ + targets: PRESSURE_TARGETS, + interventions: { lesions: ['AMY'], cuts: [], stimuli: {} }, + seed: 'lesion', + ticks: 80, + }); + for (const frame of lesioned.trace) { + expect(frame.activities.AMY).toBe(0); + expect(frame.spikes.AMY).toBe(false); + } + // AMY is BG's only input, so BG loses its excitatory drive entirely. + const lastFrame = lesioned.trace[lesioned.trace.length - 1]; + expect(lastFrame.drive.BG.excitatory).toBe(0); + }); + + it('freezes a cut pathway rather than deleting it', () => { + const { trace } = runBrainTrial({ + targets: PRESSURE_TARGETS, + interventions: { lesions: [], cuts: ['BG-THL'], stimuli: {} }, + seed: 'cut', + ticks: 60, + }); + const initial = PATHWAYS.find((pathway) => pathway.id === 'BG-THL').initialWeight; + for (const frame of trace) { + expect(frame.weights['BG-THL']).toBe(initial); + // THL keeps its excitatory drive but loses the inhibitory brake. + expect(frame.drive.THL.inhibitory).toBe(0); + } + }); + + it('raises a region when current is injected', () => { + const base = runBrainTrial({ targets: CALM_TARGETS, seed: 'inject', ticks: 60 }); + const boosted = runBrainTrial({ + targets: CALM_TARGETS, + interventions: { lesions: [], cuts: [], stimuli: { PFC: 0.25 } }, + seed: 'inject', + ticks: 60, + }); + const meanOf = (trial) => trial.trace.reduce((sum, frame) => sum + frame.activities.PFC, 0) / trial.trace.length; + expect(meanOf(boosted)).toBeGreaterThan(meanOf(base)); + }); +}); + +describe('spikeProbability', () => { + it('is silent at or below threshold and rises linearly above it', () => { + expect(spikeProbability(0.1)).toBe(0); + expect(spikeProbability(DEFAULT_PARAMS.spikeThreshold)).toBe(0); + expect(spikeProbability(0.8)).toBeGreaterThan(spikeProbability(0.5)); + }); + + it('cannot reach the spikeMax cap while activity is capped at 1', () => { + // (1 - 0.24) * 1.1 = 0.836 < spikeMax 0.85, so the cap is unreachable + // under DEFAULT_PARAMS. Pinned so a future retune notices the dead ceiling. + const atCeiling = spikeProbability(DEFAULT_PARAMS.activityCeiling); + expect(atCeiling).toBeLessThan(DEFAULT_PARAMS.spikeMax); + expect(Math.abs(atCeiling - 0.836)).toBeLessThan(1e-9); + }); + + it('honours a lowered cap', () => { + const params = createBrainParams({ spikeMax: 0.3 }); + expect(spikeProbability(1, params)).toBe(0.3); + }); +}); + +describe('parameters', () => { + it('defaults reproduce the original constants', () => { + const params = createBrainParams(); + expect(params.leak).toBe(0.78); + expect(params.synapticGain).toBe(0.18); + expect(params.stdpPotentiation).toBe(0.014); + expect(params.weightSetPoint).toBe(0.44); + }); + + it('accepts overrides without mutating the defaults', () => { + const params = createBrainParams({ leak: 0.5 }); + expect(params.leak).toBe(0.5); + expect(DEFAULT_PARAMS.leak).toBe(0.78); + }); + + it('steps from a fresh state without targets', () => { + const rng = createRng('no-targets'); + const next = stepBrain(createBrainState(), { rng }); + expect(next.tick).toBe(1); + expect(Number.isFinite(next.meanFiring)).toBe(true); + }); +}); diff --git a/brainsnn-r3f-app/src/features/brain3d/useBrainSimulation.js b/brainsnn-r3f-app/src/features/brain3d/useBrainSimulation.js index 1fd9bce..9f7c5cc 100644 --- a/brainsnn-r3f-app/src/features/brain3d/useBrainSimulation.js +++ b/brainsnn-r3f-app/src/features/brain3d/useBrainSimulation.js @@ -1,141 +1,68 @@ -// Spiking-neuron simulation for the 3D brain. Ported from -// ui/brainsnn-site/src/hooks/useBrainSimulation.js with two changes: -// - `targetActivities` biases each region's homeostatic set point so scan -// results / demo presets shape the ambient firing pattern. -// - `running` pauses the tick from the host (visibility/intersection gating). -import { useEffect, useMemo, useRef, useState } from 'react'; -import { BRAIN_REGIONS, PATHWAYS } from './brainRegions.js'; - -function clamp(value, min, max) { - return Math.max(min, Math.min(max, value)); -} - -function mean(values) { - if (!values.length) return 0; - return values.reduce((sum, value) => sum + value, 0) / values.length; -} - -function randomRange(min, max) { - return min + Math.random() * (max - min); -} - -const INITIAL_ACTIVITIES = Object.fromEntries(BRAIN_REGIONS.map((region) => [region.code, region.baseActivity])); -const INITIAL_WEIGHTS = Object.fromEntries(PATHWAYS.map((pathway) => [pathway.id, pathway.initialWeight])); -const INITIAL_LAST_SPIKE = Object.fromEntries(BRAIN_REGIONS.map((region) => [region.code, -999])); - -function computeIncomingSignal(regionCode, activities, weights) { - return PATHWAYS.filter((pathway) => pathway.to === regionCode).reduce((total, pathway) => { - const sign = pathway.inhibitory ? -1 : 1; - return total + activities[pathway.from] * weights[pathway.id] * sign; - }, 0); -} - -function spikeProbability(activity) { - return clamp((activity - 0.24) * 1.1, 0, 0.85); -} - -function stepSimulation(previous, targets) { - const nextTick = previous.tick + 1; - const burstFrames = Math.max(0, previous.burstFrames - 1); - - const nextActivities = {}; - const nextLastSpike = { ...previous.lastSpike }; - const spiked = {}; - - for (const region of BRAIN_REGIONS) { - const incoming = computeIncomingSignal(region.code, previous.activities, previous.weights); - const thalamicBurst = region.code === 'THL' ? burstFrames * 0.018 : 0; - // Blend the anatomical base with the externally-driven target so scan data - // steers the equilibrium without freezing the dynamics. - const target = targets?.[region.code] != null - ? region.baseActivity * 0.3 + targets[region.code] * 0.7 - : region.baseActivity; - const homeostasis = (target - previous.activities[region.code]) * 0.12; - const replayBoost = region.code === 'HPC' || region.code === 'CTX' - ? (previous.activities.HPC + previous.activities.CTX) * 0.022 - : 0; - - const noise = randomRange(-0.018, 0.018); - const nextActivity = clamp( - previous.activities[region.code] * 0.78 + incoming * 0.18 + homeostasis + replayBoost + thalamicBurst + noise, - 0.03, - 1, - ); - - nextActivities[region.code] = nextActivity; - - if (Math.random() < spikeProbability(nextActivity)) { - nextLastSpike[region.code] = nextTick; - spiked[region.code] = true; - } else { - spiked[region.code] = false; - } - } - - const nextWeights = {}; - for (const pathway of PATHWAYS) { - const preTime = nextLastSpike[pathway.from]; - const postTime = nextLastSpike[pathway.to]; - const dt = postTime - preTime; - let delta = 0; - - if (Math.abs(dt) <= 5) { - if (dt > 0) delta += Math.exp(-Math.abs(dt) / 3.2) * 0.014; - else if (dt < 0) delta -= Math.exp(-Math.abs(dt) / 3.2) * 0.013; - } - - if (burstFrames > 0 && pathway.from === 'THL') delta += 0.0035; - - const homeostaticPull = (0.44 - previous.weights[pathway.id]) * 0.015; - const activityCoupling = nextActivities[pathway.from] * nextActivities[pathway.to] * 0.0065; - const inhibitionDrag = pathway.inhibitory ? -0.001 : 0; - - nextWeights[pathway.id] = clamp( - previous.weights[pathway.id] + delta + homeostaticPull + activityCoupling + inhibitionDrag, - 0.08, - 0.95, - ); - } - - return { - ...previous, - tick: nextTick, - burstFrames, - activities: nextActivities, - weights: nextWeights, - lastSpike: nextLastSpike, - spikes: spiked, - meanFiring: mean(Object.values(nextActivities)), - }; -} - -export function useBrainSimulation({ running = true, targetActivities = null } = {}) { - const initialState = useMemo(() => ({ - tick: 0, - burstFrames: 0, - selectedRegion: null, - activities: INITIAL_ACTIVITIES, - weights: INITIAL_WEIGHTS, - lastSpike: INITIAL_LAST_SPIKE, - spikes: Object.fromEntries(BRAIN_REGIONS.map((region) => [region.code, false])), - meanFiring: mean(Object.values(INITIAL_ACTIVITIES)), - }), []); - - const [state, setState] = useState(initialState); +// React wrapper around the pure brain model in brainModel.js. +// +// The dynamics, parameters and interventions all live in the model so they can +// be seeded, unit-tested and replayed headlessly. This file owns only the React +// concerns: the tick timer, state plumbing and the control verbs. +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { createRng } from '../../lib/rng.js'; +import { + createBrainParams, + createBrainState, + DEFAULT_PARAMS, + EMPTY_INTERVENTIONS, + stepBrain, +} from './brainModel.js'; + +export function useBrainSimulation({ + running = true, + targetActivities = null, + seed = 'brainsnn', + params: paramOverrides = null, + speed = 1, +} = {}) { + const params = useMemo(() => createBrainParams(paramOverrides), [paramOverrides]); + const [state, setState] = useState(() => createBrainState(params)); + const [paused, setPaused] = useState(false); + const [interventions, setInterventions] = useState(EMPTY_INTERVENTIONS); + + // The render loop reads these through refs so changing a target or an + // intervention never restarts the timer. const targetsRef = useRef(targetActivities); targetsRef.current = targetActivities; - + const paramsRef = useRef(params); + paramsRef.current = params; + const interventionsRef = useRef(interventions); + interventionsRef.current = interventions; + + // Seeded so the same content always produces the same run; re-seeded whenever + // the seed changes so a new scan starts a fresh, reproducible trajectory. + const rngRef = useRef(null); + if (rngRef.current === null) rngRef.current = createRng(seed); + useEffect(() => { + rngRef.current = createRng(seed); + }, [seed]); + + const advance = useCallback(() => { + setState((previous) => stepBrain(previous, { + targets: targetsRef.current, + params: paramsRef.current, + interventions: interventionsRef.current, + rng: rngRef.current, + })); + }, []); + + const intervalMs = Math.max(16, params.tickMs / Math.max(0.1, speed)); useEffect(() => { - if (!running) return undefined; - const timer = window.setInterval(() => { - setState((previous) => stepSimulation(previous, targetsRef.current)); - }, 120); + if (!running || paused) return undefined; + const timer = window.setInterval(advance, intervalMs); return () => window.clearInterval(timer); - }, [running]); + }, [running, paused, intervalMs, advance]); // A burst makes the switch to a new target/preset visibly ripple through. useEffect(() => { - if (targetActivities) setState((previous) => ({ ...previous, burstFrames: 18 })); + if (targetActivities) { + setState((previous) => ({ ...previous, burstFrames: paramsRef.current.burstTicks })); + } }, [targetActivities]); const controls = useMemo(() => ({ @@ -148,7 +75,56 @@ export function useBrainSimulation({ running = true, targetActivities = null } = clearSelection() { setState((previous) => ({ ...previous, selectedRegion: null })); }, - }), []); + togglePaused() { + setPaused((previous) => !previous); + }, + step() { + advance(); + }, + triggerBurst() { + setState((previous) => ({ ...previous, burstFrames: paramsRef.current.burstTicks })); + }, + reset() { + rngRef.current = createRng(seed); + setInterventions(EMPTY_INTERVENTIONS); + setState(createBrainState(paramsRef.current)); + }, + // Inject a sustained current into one region (0 clears it). + stimulate(regionCode, amount = 0.2) { + setInterventions((previous) => ({ + ...previous, + stimuli: { ...previous.stimuli, [regionCode]: amount }, + })); + }, + clearStimulus(regionCode) { + setInterventions((previous) => { + const stimuli = { ...previous.stimuli }; + delete stimuli[regionCode]; + return { ...previous, stimuli }; + }); + }, + toggleLesion(regionCode) { + setInterventions((previous) => { + const lesions = previous.lesions.includes(regionCode) + ? previous.lesions.filter((code) => code !== regionCode) + : [...previous.lesions, regionCode]; + return { ...previous, lesions }; + }); + }, + toggleCut(pathwayId) { + setInterventions((previous) => { + const cuts = previous.cuts.includes(pathwayId) + ? previous.cuts.filter((id) => id !== pathwayId) + : [...previous.cuts, pathwayId]; + return { ...previous, cuts }; + }); + }, + clearInterventions() { + setInterventions(EMPTY_INTERVENTIONS); + }, + }), [advance, seed]); - return { state, controls }; + return { state: { ...state, paused, interventions, params }, controls }; } + +export { DEFAULT_PARAMS }; diff --git a/brainsnn-r3f-app/src/features/export/ShareDialog.jsx b/brainsnn-r3f-app/src/features/export/ShareDialog.jsx index 2478398..e3dce64 100644 --- a/brainsnn-r3f-app/src/features/export/ShareDialog.jsx +++ b/brainsnn-r3f-app/src/features/export/ShareDialog.jsx @@ -2,7 +2,8 @@ import React, { useEffect, useMemo, useState } from 'react'; import { Copy, Download, FileJson, FileText, Link2, Share2 } from 'lucide-react'; import { Modal } from '../../components/ui/Modal.jsx'; import { track } from '../../lib/analytics.js'; -import { deriveExecutiveVerdict, getBusinessMetrics } from '../../lib/scoreMapping.js'; +import { deriveExecutiveVerdict } from '../../lib/scoreMapping.js'; +import { getHeadlineScores } from '../../lib/headlineScores.js'; import { ExportCard } from './ExportCard.jsx'; import { SharePreview } from './SharePreview.jsx'; import { downloadBlob, renderScoreCardBlob, shareScoreCard } from './scoreCard.js'; @@ -17,8 +18,8 @@ export function ShareDialog({ open, onClose, result }) { const verdict = result ? deriveExecutiveVerdict(result) : null; const shareText = useMemo(() => { if (!result || !verdict) return ''; - const metricMap = Object.fromEntries(getBusinessMetrics(result).map((metric) => [metric.id, metric.value])); - return `BRAIN SCAN\n"${verdict.headline}"\n\nHook Strength: ${metricMap.hookStrength}\nTrust: ${metricMap.trust}\nViral Pull: ${verdict.viralScore}\nManipulation Risk: ${metricMap.manipulationRisk}\n\nAI-estimated content response from BrainSNN — scan yours free at brainsnn.com`; + const lines = getHeadlineScores(result).map((metric) => `${metric.label}: ${metric.value}`).join('\n'); + return `BRAIN SCAN\n"${verdict.headline}"\n\n${lines}\n\nAI-estimated content response from BrainSNN — scan yours free at brainsnn.com`; }, [result, verdict]); useEffect(() => { diff --git a/brainsnn-r3f-app/src/features/export/SharePreview.jsx b/brainsnn-r3f-app/src/features/export/SharePreview.jsx index 83808b4..2146abd 100644 --- a/brainsnn-r3f-app/src/features/export/SharePreview.jsx +++ b/brainsnn-r3f-app/src/features/export/SharePreview.jsx @@ -1,9 +1,9 @@ import React from 'react'; -import { deriveExecutiveVerdict, getBusinessMetrics } from '../../lib/scoreMapping.js'; +import { deriveExecutiveVerdict } from '../../lib/scoreMapping.js'; +import { getHeadlineScores } from '../../lib/headlineScores.js'; export function SharePreview({ result }) { const verdict = deriveExecutiveVerdict(result); - const metricMap = Object.fromEntries(getBusinessMetrics(result).map((metric) => [metric.id, metric.value])); return (
BRAIN SCAN @@ -12,10 +12,9 @@ export function SharePreview({ result }) {

AI-estimated content response

-
Hook Strength
{metricMap.hookStrength}
-
Trust
{metricMap.trust}
-
Viral Pull
{verdict.viralScore}
-
Manipulation Risk
{metricMap.manipulationRisk}
+ {getHeadlineScores(result).map((metric) => ( +
{metric.label}
{metric.value}
+ ))}
brainsnn.com
diff --git a/brainsnn-r3f-app/src/features/export/scoreCard.js b/brainsnn-r3f-app/src/features/export/scoreCard.js index 14db653..b72fa86 100644 --- a/brainsnn-r3f-app/src/features/export/scoreCard.js +++ b/brainsnn-r3f-app/src/features/export/scoreCard.js @@ -1,6 +1,7 @@ // Shareable score card: composes the text payload (pure, unit-testable) and // renders a 1200x630 branded PNG. Extends the previous ShareDialog downloadPng. -import { deriveExecutiveVerdict, getBusinessMetrics } from '../../lib/scoreMapping.js'; +import { deriveExecutiveVerdict } from '../../lib/scoreMapping.js'; +import { getHeadlineScores } from '../../lib/headlineScores.js'; import { synchronyPalette } from '../brain3d/mapResultToBrain.js'; import { BRAIN_REGIONS } from '../brain3d/brainRegions.js'; import { mapResultToActivities } from '../brain3d/mapResultToBrain.js'; @@ -9,7 +10,7 @@ export function composeScoreCardText(result = {}) { // Explicit null bypasses the default parameter. const safeResult = result || {}; const verdict = deriveExecutiveVerdict(safeResult); - const metricMap = Object.fromEntries(getBusinessMetrics(safeResult).map((metric) => [metric.id, metric.value])); + const headlineMap = Object.fromEntries(getHeadlineScores(safeResult).map((metric) => [metric.label, metric.value])); const raw = String(safeResult.rawContent || '').replace(/\s+/g, ' ').trim(); const excerpt = raw.length > 90 ? `${raw.slice(0, 87).trimEnd()}…` : raw; return { @@ -18,11 +19,13 @@ export function composeScoreCardText(result = {}) { viralScore: verdict.viralScore, viralLabel: verdict.viralLabel, excerpt, + // The same four headline scores the playground shows, so shared cards and + // the live simulator never disagree. metricRows: [ - ['Hook Strength', metricMap.hookStrength, 'good'], - ['Trust', metricMap.trust, 'good'], - ['Viral Pull', verdict.viralScore, 'good'], - ['Manipulation Risk', metricMap.manipulationRisk, 'risk'], + ['Attention', headlineMap.Attention, 'good'], + ['Trust', headlineMap.Trust, 'good'], + ['Emotional Charge', headlineMap['Emotional Charge'], 'good'], + ['Manipulation Risk', headlineMap['Manipulation Risk'], 'risk'], ], footer: 'brainsnn.com', disclaimer: 'AI-estimated content response', diff --git a/brainsnn-r3f-app/src/features/export/scoreCard.test.js b/brainsnn-r3f-app/src/features/export/scoreCard.test.js index b067986..3284d7b 100644 --- a/brainsnn-r3f-app/src/features/export/scoreCard.test.js +++ b/brainsnn-r3f-app/src/features/export/scoreCard.test.js @@ -16,11 +16,11 @@ describe('composeScoreCardText', () => { expect(card.excerpt.endsWith('…')).toBe(true); }); - it('includes viral pull and manipulation risk rows', () => { + it('includes the four playground headline rows', () => { const card = composeScoreCardText(mockResult); const labels = card.metricRows.map(([label]) => label); - expect(labels).toContain('Viral Pull'); - expect(labels).toContain('Manipulation Risk'); + expect(labels).toEqual(['Attention', 'Trust', 'Emotional Charge', 'Manipulation Risk']); + for (const [, value] of card.metricRows) expect(Number.isFinite(value)).toBe(true); expect(card.footer).toBe('brainsnn.com'); }); diff --git a/brainsnn-r3f-app/src/features/gaugegap/ArcadeProgress.jsx b/brainsnn-r3f-app/src/features/gaugegap/ArcadeProgress.jsx index bccef85..1192587 100644 --- a/brainsnn-r3f-app/src/features/gaugegap/ArcadeProgress.jsx +++ b/brainsnn-r3f-app/src/features/gaugegap/ArcadeProgress.jsx @@ -1,31 +1,24 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { CalendarDays, Flame, Medal, Star, Trophy } from 'lucide-react'; +import { + ACHIEVEMENT_XP, + applyAchievement, + applyVisit, + dateKey, + emptyProgress, + levelFor, + normalizeProgress, + STORAGE_KEY, + yesterdayKey, +} from './arcadeProgressCore.js'; -const STORAGE_KEY = 'gaugegap-arcade-progress-v1'; - -function dateKey(date = new Date()) { - return date.toISOString().slice(0, 10); -} - -function yesterdayKey() { - const date = new Date(); - date.setDate(date.getDate() - 1); - return dateKey(date); -} function readProgress() { - if (typeof window === 'undefined') return { visited: [], xp: 0, streak: 0, lastVisit: '', dailyWins: [] }; + if (typeof window === 'undefined') return emptyProgress(); try { - const parsed = JSON.parse(window.localStorage.getItem(STORAGE_KEY) || '{}'); - return { - visited: Array.isArray(parsed.visited) ? parsed.visited : [], - xp: Number(parsed.xp) || 0, - streak: Number(parsed.streak) || 0, - lastVisit: parsed.lastVisit || '', - dailyWins: Array.isArray(parsed.dailyWins) ? parsed.dailyWins : [], - }; + return normalizeProgress(JSON.parse(window.localStorage.getItem(STORAGE_KEY) || '{}')); } catch { - return { visited: [], xp: 0, streak: 0, lastVisit: '', dailyWins: [] }; + return emptyProgress(); } } @@ -45,25 +38,21 @@ export function useArcadeProgress(experiments) { }, [progress]); const recordVisit = useCallback((id) => { - const today = dateKey(); - setProgress((current) => { - const firstVisit = !current.visited.includes(id); - const firstToday = current.lastVisit !== today; - const dailyWin = id === dailyLab?.id && !current.dailyWins.includes(today); - let streak = current.streak; - if (firstToday) streak = current.lastVisit === yesterdayKey() ? Math.max(1, current.streak + 1) : 1; - return { - visited: firstVisit ? [...current.visited, id] : current.visited, - xp: current.xp + (firstVisit ? 25 : 2) + (dailyWin ? 50 : 0), - streak, - lastVisit: today, - dailyWins: dailyWin ? [...current.dailyWins.slice(-29), today] : current.dailyWins, - }; - }); + setProgress((current) => applyVisit(current, { + id, + dailyLabId: dailyLab?.id, + today: dateKey(), + yesterday: yesterdayKey(), + })); }, [dailyLab?.id]); - const level = Math.floor(progress.xp / 125) + 1; - const levelProgress = progress.xp % 125; + // Pay XP for something the player actually did inside a lab. Idempotent: + // re-earning an achievement never pays twice. + const recordAchievement = useCallback((id, options = {}) => { + setProgress((current) => applyAchievement(current, id, options)); + }, []); + + const { level, levelProgress } = levelFor(progress.xp); const achievements = useMemo(() => [ { id: 'first', label: 'First Contact', unlocked: progress.visited.length >= 1 }, { id: 'sampler', label: 'System Sampler', unlocked: progress.visited.length >= 4 }, @@ -71,9 +60,11 @@ export function useArcadeProgress(experiments) { { id: 'completionist', label: 'Foundry Completionist', unlocked: progress.visited.length >= experiments.length }, { id: 'streak', label: 'Three-Day Signal', unlocked: progress.streak >= 3 }, { id: 'daily', label: 'Daily Challenger', unlocked: progress.dailyWins.length >= 3 }, - ], [experiments.length, progress.dailyWins.length, progress.streak, progress.visited.length]); + { id: 'defender', label: 'Held the Line', unlocked: progress.unlocked.includes('defender') }, + { id: 'efficient-defender', label: 'Minimal Intervention', unlocked: progress.unlocked.includes('efficient-defender') }, + ], [experiments.length, progress.dailyWins.length, progress.streak, progress.visited.length, progress.unlocked]); - return { progress, recordVisit, dailyLab, level, levelProgress, achievements }; + return { progress, recordVisit, recordAchievement, dailyLab, level, levelProgress, achievements }; } export function ArcadeProgress({ progress, level, levelProgress, achievements, dailyLab, onOpenDaily }) { diff --git a/brainsnn-r3f-app/src/features/gaugegap/AttractorPlayground.jsx b/brainsnn-r3f-app/src/features/gaugegap/AttractorPlayground.jsx index 8a16a0f..27ffe14 100644 --- a/brainsnn-r3f-app/src/features/gaugegap/AttractorPlayground.jsx +++ b/brainsnn-r3f-app/src/features/gaugegap/AttractorPlayground.jsx @@ -1,5 +1,6 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; -import { Copy, Download, Pause, Play, RotateCcw, Share2, Sparkles, Video } from 'lucide-react'; +import { Copy, Download, FileJson, Pause, Play, RotateCcw, Share2, Sparkles, Video } from 'lucide-react'; +import { buildAttractorEvidence, downloadJson } from './evidence.js'; const PRESETS = [ { id: 'classic', label: 'Classic butterfly', sigma: 10, rho: 28, beta: 2.667, speed: 1 }, @@ -288,6 +289,12 @@ export function AttractorPlayground() { { key: 'speed', label: 'Time', min: 0.35, max: 2, step: 0.05 }, ]; + async function exportEvidence() { + const pack = await buildAttractorEvidence({ params, scores, shareUrl: getShareUrl(), controls }); + downloadJson(`gaugegap-evidence-attractor-${String(pack.content_hash).slice(0, 8)}.json`, pack); + setNotice('Evidence pack saved: exact parameters, solver settings and a content hash travel with your run.'); + } + return (
@@ -366,6 +373,7 @@ export function AttractorPlayground() { +

{notice}

diff --git a/brainsnn-r3f-app/src/features/gaugegap/BrainGameLab.jsx b/brainsnn-r3f-app/src/features/gaugegap/BrainGameLab.jsx new file mode 100644 index 0000000..968b455 --- /dev/null +++ b/brainsnn-r3f-app/src/features/gaugegap/BrainGameLab.jsx @@ -0,0 +1,473 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { Activity, Brain, FileJson, Pause, Play, RotateCcw, ShieldCheck, SkipForward, Zap } from 'lucide-react'; +import { BRAIN_REGIONS, PATHWAYS, REGION_MAP } from '../brain3d/brainRegions.js'; +import { createBrainParams, createBrainState, stepBrain } from '../brain3d/brainModel.js'; +import { createRng } from '../../lib/rng.js'; +import { BRAIN_CLAIM_BOUNDARY } from '../brain3d/brainMetrics.js'; +import { copyChallenge, DeepLabHeading, shareChallenge } from './DeepLabChrome.jsx'; +import { + applyIntervention, + CALM_DRIVE, + CHALLENGE, + countInterventions, + driveAtTick, + evaluateRun, + GAME_MODES, + INTERVENTIONS, + MISSION, + missionNotice, +} from './brainGame.js'; +import { buildRunProof, verifyRunProof } from './runProof.js'; +import { downloadJson } from './evidence.js'; +import { track } from '../../lib/analytics.js'; + +const EMPTY = { lesions: [], cuts: [], stimuli: {} }; +const TRACE_WINDOW = 60; + +// Project the 3D region layout onto the canvas. x and z read best from above. +function layoutRegions(width, height) { + const xs = BRAIN_REGIONS.map((region) => region.position[0]); + const zs = BRAIN_REGIONS.map((region) => region.position[2]); + const minX = Math.min(...xs); + const maxX = Math.max(...xs); + const minZ = Math.min(...zs); + const maxZ = Math.max(...zs); + const padX = width * 0.16; + const padY = height * 0.18; + return Object.fromEntries(BRAIN_REGIONS.map((region) => [region.code, { + x: padX + ((region.position[0] - minX) / Math.max(1e-6, maxX - minX)) * (width - padX * 2), + y: padY + ((region.position[2] - minZ) / Math.max(1e-6, maxZ - minZ)) * (height - padY * 2), + }])); +} + +export function BrainGameLab({ onAchievement }) { + const [mode, setMode] = useState('mission'); + const [paused, setPaused] = useState(false); + const [interventions, setInterventions] = useState(EMPTY); + const [evaluation, setEvaluation] = useState(() => evaluateRun({ + frames: [], finalState: null, params: createBrainParams(), targets: null, mode: 'mission', used: 0, + })); + const [selected, setSelected] = useState(null); + const [notice, setNotice] = useState(''); + const [seed, setSeed] = useState('defend-01'); + const [resetKey, setResetKey] = useState(0); + const [log, setLog] = useState([]); + const [verdict, setVerdict] = useState(null); + const tickRef = useRef(0); + + const canvasRef = useRef(null); + const modeRef = useRef(mode); + const pausedRef = useRef(paused); + const interventionsRef = useRef(interventions); + const stepOnceRef = useRef(false); + useEffect(() => { modeRef.current = mode; }, [mode]); + useEffect(() => { pausedRef.current = paused; }, [paused]); + useEffect(() => { interventionsRef.current = interventions; }, [interventions]); + + const rules = mode === 'challenge' ? CHALLENGE : MISSION; + const used = countInterventions(interventions); + const finished = evaluation.status === 'won' || evaluation.status === 'lost'; + + // XP for an actual accomplishment rather than for opening the lab. Fires once + // per outcome; recordAchievement is itself idempotent. + const awardedRef = useRef(''); + useEffect(() => { + if (evaluation.status !== 'won' || mode === 'sandbox') return; + const key = `${mode}-${evaluation.scores.defense}`; + if (awardedRef.current === key) return; + awardedRef.current = key; + onAchievement?.('defender', { score: evaluation.scores.defense, labId: 'braingame' }); + if (evaluation.remaining >= rules.budget - 1) { + onAchievement?.('efficient-defender', { score: evaluation.scores.defense, labId: 'braingame' }); + } + track('gaugegap_brain_mission_won', { mode, defense: evaluation.scores.defense }); + }, [evaluation.status, evaluation.scores.defense, evaluation.remaining, mode, onAchievement, rules.budget]); + + // The simulation owns its own loop so the canvas never waits on React. + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return undefined; + const context = canvas.getContext('2d'); + if (!context) return undefined; + + const params = createBrainParams(); + const rng = createRng(seed); + let state = createBrainState(params); + let frames = []; + let breachTicks = 0; + let worstBreach = 0; + let width = 0; + let height = 0; + let animation = 0; + let lastStep = 0; + let lastPublish = 0; + let positions = {}; + + function resize() { + const rect = canvas.getBoundingClientRect(); + const ratio = Math.min(window.devicePixelRatio || 1, 2); + width = Math.max(320, rect.width); + height = Math.max(320, rect.height); + canvas.width = Math.floor(width * ratio); + canvas.height = Math.floor(height * ratio); + context.setTransform(ratio, 0, 0, ratio, 0, 0); + positions = layoutRegions(width, height); + canvas._positions = positions; + } + const observer = new ResizeObserver(resize); + observer.observe(canvas); + resize(); + + function advance() { + const activeMode = modeRef.current; + const total = activeMode === 'challenge' ? CHALLENGE.durationTicks : MISSION.durationTicks; + const targets = activeMode === 'sandbox' ? CALM_DRIVE : driveAtTick(frames.length, total); + state = stepBrain(state, { targets, params, interventions: interventionsRef.current, rng }); + frames.push({ + tick: state.tick, + activities: state.activities, + spikes: state.spikes, + weights: state.weights, + drive: state.drive, + stdpDelta: state.stdpDelta, + meanFiring: state.meanFiring, + }); + if (frames.length > total + 5) frames = frames.slice(-total); + + const window_ = frames.slice(-40); + const raw = window_.reduce((sum, frame) => ( + sum + ((frame.activities.AMY + frame.activities.BG) / 2 - frame.activities.PFC) + ), 0) / window_.length; + const hijackIndex = Math.round(Math.max(0, Math.min(1, raw + 0.5)) * 100); + const limit = activeMode === 'challenge' ? CHALLENGE.hijackTarget : MISSION.hijackLimit; + breachTicks = hijackIndex > limit ? breachTicks + 1 : 0; + worstBreach = Math.max(worstBreach, breachTicks); + tickRef.current = frames.length; + return { targets, params }; + } + + function draw(now) { + const activeMode = modeRef.current; + const total = activeMode === 'challenge' ? CHALLENGE.durationTicks : MISSION.durationTicks; + const done = activeMode !== 'sandbox' && frames.length >= total; + const failed = activeMode === 'mission' && worstBreach >= MISSION.breachGrace; + + if ((!pausedRef.current && !done && !failed) || stepOnceRef.current) { + if (now - lastStep > 120 || stepOnceRef.current) { + lastStep = now; + stepOnceRef.current = false; + advance(); + } + } + + context.fillStyle = '#04040c'; + context.fillRect(0, 0, width, height); + + const latest = frames[frames.length - 1]; + const activities = latest ? latest.activities : {}; + const weights = latest ? latest.weights : {}; + const cuts = interventionsRef.current.cuts || []; + const lesions = interventionsRef.current.lesions || []; + const stimuli = interventionsRef.current.stimuli || {}; + + // Pathways + for (const pathway of PATHWAYS) { + const from = positions[pathway.from]; + const to = positions[pathway.to]; + if (!from || !to) continue; + const isCut = cuts.includes(pathway.id); + const weight = weights[pathway.id] ?? pathway.initialWeight; + context.save(); + context.strokeStyle = isCut ? 'rgba(120,120,140,0.35)' : pathway.inhibitory ? '#fb7185' : 'rgba(125,249,255,0.55)'; + context.lineWidth = isCut ? 1 : 1 + weight * 4; + if (isCut) context.setLineDash([4, 6]); + context.beginPath(); + context.moveTo(from.x, from.y); + context.lineTo(to.x, to.y); + context.stroke(); + context.restore(); + } + + // Regions + for (const region of BRAIN_REGIONS) { + const point = positions[region.code]; + if (!point) continue; + const activity = activities[region.code] ?? region.baseActivity; + const lesioned = lesions.includes(region.code); + const stimulated = stimuli[region.code] > 0; + const spiking = latest?.spikes?.[region.code]; + const radius = 12 + activity * 26; + + context.beginPath(); + context.arc(point.x, point.y, radius, 0, Math.PI * 2); + context.fillStyle = lesioned ? 'rgba(90,90,110,0.45)' : region.color; + context.globalAlpha = lesioned ? 0.5 : 0.35 + activity * 0.5; + context.fill(); + context.globalAlpha = 1; + + if (spiking && !lesioned) { + context.beginPath(); + context.arc(point.x, point.y, radius + 5, 0, Math.PI * 2); + context.strokeStyle = '#ffffff'; + context.lineWidth = 2; + context.stroke(); + } + if (stimulated) { + context.beginPath(); + context.arc(point.x, point.y, radius + 10, 0, Math.PI * 2); + context.strokeStyle = '#d9ff65'; + context.lineWidth = 1.5; + context.stroke(); + } + if (lesioned) { + context.strokeStyle = '#ff5873'; + context.lineWidth = 2.5; + context.beginPath(); + context.moveTo(point.x - 9, point.y - 9); + context.lineTo(point.x + 9, point.y + 9); + context.moveTo(point.x + 9, point.y - 9); + context.lineTo(point.x - 9, point.y + 9); + context.stroke(); + } + + context.fillStyle = '#e7fbff'; + context.font = '600 11px ui-monospace, monospace'; + context.textAlign = 'center'; + context.fillText(region.code, point.x, point.y + radius + 15); + } + context.textAlign = 'left'; + + if (now - lastPublish > 200) { + lastPublish = now; + const activeTotal = activeMode === 'challenge' ? CHALLENGE.durationTicks : MISSION.durationTicks; + const targets = activeMode === 'sandbox' ? CALM_DRIVE : driveAtTick(frames.length, activeTotal); + setEvaluation(evaluateRun({ + frames: frames.slice(-TRACE_WINDOW), + finalState: state, + params, + targets, + mode: activeMode, + used: countInterventions(interventionsRef.current), + breachTicks, + worstBreach, + elapsed: frames.length, + })); + } + + animation = window.requestAnimationFrame(draw); + } + + animation = window.requestAnimationFrame(draw); + return () => { + observer.disconnect(); + window.cancelAnimationFrame(animation); + }; + }, [seed, resetKey]); + + function chooseIntervention(choice) { + if (finished) return; + if (mode !== 'sandbox' && used >= rules.budget) { + setNotice('Budget spent. Reset to try a different approach.'); + return; + } + setInterventions((previous) => applyIntervention(previous, choice)); + setLog((previous) => [...previous, { tick: tickRef.current, id: choice.id }]); + setNotice(choice.hint); + track('gaugegap_brain_intervention', { id: choice.id, mode }); + } + + function reset(nextMode = mode) { + setInterventions(EMPTY); + setLog([]); + tickRef.current = 0; + setPaused(false); + setSelected(null); + setNotice(''); + setMode(nextMode); + setResetKey((key) => key + 1); + awardedRef.current = ''; + } + + function handleCanvasClick(event) { + const canvas = canvasRef.current; + const positions = canvas?._positions; + if (!positions) return; + const rect = canvas.getBoundingClientRect(); + const x = event.clientX - rect.left; + const y = event.clientY - rect.top; + let closest = null; + let bestDistance = Infinity; + for (const [code, point] of Object.entries(positions)) { + const distance = Math.hypot(point.x - x, point.y - y); + if (distance < bestDistance) { + bestDistance = distance; + closest = code; + } + } + if (closest && bestDistance < 44) { + setSelected(closest); + setNotice(`${REGION_MAP[closest]?.name || closest}: ${REGION_MAP[closest]?.description || ''}`); + } + } + + const gameUrl = useMemo(() => { + if (typeof window === 'undefined') return ''; + const url = new URL(window.location.href); + url.searchParams.set('lab', 'braingame'); + url.searchParams.set('state', seed); + url.hash = 'playground'; + return url.toString(); + }, [seed]); + + const modeMeta = GAME_MODES.find((entry) => entry.id === mode) || GAME_MODES[0]; + + return ( +
+ + +
+
+ +
+ {selected ? `${REGION_MAP[selected]?.name}` : 'THL → CTX → AMY → BG ⊣ THL — the one closed loop'} +
+
+ rules.hijackLimit || evaluation.hijack > (rules.hijackTarget ?? 100) ? 'danger' : ''}> + Hijack {evaluation.hijack} + + Control {evaluation.control} + {mode !== 'sandbox' ? Budget {evaluation.remaining}/{rules.budget} : null} + {mode !== 'sandbox' ? Tick {evaluation.elapsed}/{rules.durationTicks} : null} +
+ {finished ? ( +
+ {evaluation.status === 'won' ? 'Held the line' : 'Judgment offline'} + Defense {evaluation.scores.defense} · control {evaluation.scores.control} · stability {evaluation.scores.stability} · efficiency {evaluation.scores.efficiency} + +
+ ) : null} +
+ + +
+ +
+ + + + + {verdict ? ( + + {verdict.verified ? '✓ replay matches' : `✗ ${verdict.problems.length} problem(s)`} + + ) : null} + {notice || missionNotice(evaluation, mode)} +
+
+ ); +} diff --git a/brainsnn-r3f-app/src/features/gaugegap/ContentReactionLab.jsx b/brainsnn-r3f-app/src/features/gaugegap/ContentReactionLab.jsx new file mode 100644 index 0000000..f7ed1e8 --- /dev/null +++ b/brainsnn-r3f-app/src/features/gaugegap/ContentReactionLab.jsx @@ -0,0 +1,341 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { ArrowRight, ClipboardPaste, Copy, Eraser, Link2, Play, Share2, Sigma, Sparkles, WandSparkles } from 'lucide-react'; +import { Brain3D } from '../brain3d/Brain3D.jsx'; +import { BrainVisualizer } from '../results/BrainVisualizer.jsx'; +import { ShareDialog } from '../export/ShareDialog.jsx'; +import { createRewrite } from '../improve/rewrite.js'; +import { isKeyboardScanShortcut } from '../scan/keyboard.js'; +import { getHeadlineScores } from '../../lib/headlineScores.js'; +import { topDrivers } from '../../lib/ablation.js'; +import { describePercentile, SCORE_SCALE_NOTE } from '../../lib/scoreReference.js'; +import { calibrate, formatCalibrationCard } from '../../lib/calibration.js'; +import { getClassicPreset } from '../../lib/classicPresets.js'; +import { compareResults, deriveExecutiveVerdict } from '../../lib/scoreMapping.js'; +import { MAX_SCAN_CHARS } from '../../lib/validation.js'; +import { track } from '../../lib/analytics.js'; +import { copyChallenge, DeepLabHeading, shareChallenge } from './DeepLabChrome.jsx'; +import { buildContentShareUrl, parseSharedContent, useContentScan, scanContentLocally } from './useContentScan.js'; + +const GENRES = [ + { id: 'ad', label: 'Ad', presetId: 'luxury-scarcity-ad', placeholder: 'Paste ad copy — the promise, the pressure, the ask…' }, + { id: 'tweet', label: 'Tweet', presetId: 'outrage-bait-post', placeholder: 'Paste a post or hook — the first line does the damage…' }, + { id: 'landing', label: 'Landing page', presetId: 'guru-urgency-pitch', placeholder: 'Paste a hero section or pitch — headline to call-to-action…' }, + { id: 'email', label: 'Email', presetId: 'account-phishing-email', placeholder: 'Paste an email — subject line and body both count…' }, +]; + +const REWRITE_ACTIONS = [ + { goal: 'trust', label: 'Build trust' }, + { goal: 'urgency', label: 'Add real urgency' }, + { goal: 'reduce-risk', label: 'Reduce manipulation' }, +]; + +const PLACEHOLDER_TILES = ['Attention', 'Trust', 'Emotional Charge', 'Manipulation Risk'].map((label) => ({ id: `pending-${label}`, label })); + +function ScoreTiles({ scores, previousScores, band }) { + const previousById = previousScores ? Object.fromEntries(previousScores.map((score) => [score.id, score.value])) : {}; + return ( +
+ {(scores || PLACEHOLDER_TILES).map((score) => { + const pending = score.value == null; + const before = previousById[score.id]; + const delta = !pending && before != null ? Math.round(score.value - before) : 0; + const improved = score.direction === 'lower-good' ? delta < 0 : delta > 0; + return ( +
+ {score.label} + {pending ? '—' : score.value} + {!pending && delta !== 0 ? ( + 0 ? 'up' : 'down'} ${Math.abs(delta)} since the previous run`}> + {delta > 0 ? '▲' : '▼'} {Math.abs(delta)} + + ) : null} + {!pending && band?.[score.id]?.stderr > 0 ? ( + + ±{band[score.id].stderr} + + ) : null} + {pending ? 'Run a simulation' : describePercentile(score.id, score.value) || score.detail || score.explanation} +
+ ); + })} +
+ ); +} + +// What the engine has actually been measured to do, stated where the scores +// are read rather than buried in a README. +function CalibrationCard() { + const report = useMemo(() => calibrate(), []); + const trust = report.dimensions.trust; + return ( +
+ + How good are these numbers? + {formatCalibrationCard(report)} + + + + + + + {Object.entries(report.dimensions).map(([id, entry]) => ( + + + + + + ))} + +
DimensionRank agreementPairs ordered wrongly
{id}{entry.spearman}{entry.inversions} / {entry.comparablePairs}
+

+ {SCORE_SCALE_NOTE} Agreement is Spearman rank correlation against hand-labelled + archetypes; trust was measured at {trust.spearman} after a defect that had it + ranking backwards was fixed. +

+
+ ); +} + +function DriverPanel({ sensitivity, scores }) { + const [scoreId, setScoreId] = useState('manipulationRisk'); + if (!sensitivity) return null; + if (!sensitivity.sentences.length) { + return

{sensitivity.note}

; + } + + const drivers = topDrivers(sensitivity, scoreId, { limit: 4 }); + const label = scores?.find((score) => score.id === scoreId)?.label || scoreId; + const spread = sensitivity.orderSensitivity?.[scoreId] ?? 0; + + return ( +
+
+ Where this score comes from +
+ {(scores || []).map((score) => ( + + ))} +
+
+ + {drivers.length ? ( +
    + {drivers.map((driver) => ( +
  1. + +

    {driver.sentence}

    + +{driver.contribution} pts · {driver.share}% of {label} +
  2. + ))} +
+ ) : ( +

No single sentence raises {label} — it comes from the text as a whole.

+ )} + +

+ Each sentence is scored by removing it and re-running the scan. The ± on each tile is the + jackknife standard error over those runs. Reordering the sentences moves {label} by {spread} pts. +

+
+ ); +} + +export function ContentReactionLab({ onOpenScanner }) { + const { input, setInput, status, result, previousResult, sensitivity, error, setError, runSimulation, applyContent } = useContentScan(); + const [genreId, setGenreId] = useState('ad'); + const [shareOpen, setShareOpen] = useState(false); + const [rewrite, setRewrite] = useState(null); + const [notice, setNotice] = useState(''); + const shared = useMemo(() => (typeof window === 'undefined' ? null : parseSharedContent(window.location.search)), []); + const applyContentRef = useRef(applyContent); + applyContentRef.current = applyContent; + + // Deliberately depends on `shared` only: the effect must re-fire when + // StrictMode's dev double-invoke clears the pending scan timer, but must not + // re-apply the shared text on every keystroke (applyContent is unstable). + useEffect(() => { + if (!shared) return; + applyContentRef.current(shared); + setNotice('Challenge loaded — this exact text arrived with the shared link. Beat its scores.'); + track('gaugegap_content_challenge_opened'); + }, [shared]); + + const genre = GENRES.find((entry) => entry.id === genreId) || GENRES[0]; + const scores = result ? getHeadlineScores(result) : null; + const previousScores = previousResult ? getHeadlineScores(previousResult) : null; + const verdict = result ? deriveExecutiveVerdict(result) : null; + const running = status === 'running'; + + function handleRun(content) { + setRewrite(null); + setNotice(''); + runSimulation(content); + } + + function loadSample() { + const preset = getClassicPreset(genre.presetId); + if (!preset) return; + setRewrite(null); + applyContent(preset.content); + track('gaugegap_content_sample_loaded', { genre: genre.id }); + } + + async function pasteFromClipboard() { + try { + if (!navigator?.clipboard?.readText) { + setError('Clipboard access is not supported here — paste into the box instead.'); + return; + } + const text = await navigator.clipboard.readText(); + if (text) { + setInput(text.slice(0, MAX_SCAN_CHARS)); + setError(''); + } + } catch { + setError('Clipboard access was blocked — paste into the box instead.'); + } + } + + function challengeUrl() { + return buildContentShareUrl(window.location.href, input); + } + + async function shareChallengeLink() { + track('gaugegap_content_challenge_shared'); + await shareChallenge({ + title: 'Run my copy through a brain', + text: 'I scanned this in the BrainSNN content lab. Open the link, see the reaction, then try beating the scores with your version.', + url: challengeUrl(), + setNotice, + }); + } + + async function copyChallengeLink() { + track('gaugegap_content_challenge_copied'); + await copyChallenge(challengeUrl(), setNotice); + } + + function makeRewrite({ goal, label }) { + if (!result) return; + const text = createRewrite(input, goal); + const rewrittenResult = scanContentLocally(text); + setRewrite({ goal, label, text, deltas: compareResults(result, rewrittenResult) }); + track('gaugegap_content_rewrite_created', { goal }); + } + + return ( +
+ + +
+
+
+ {GENRES.map((entry) => ( + + ))} +
+