Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion proposals/MIGRATION-PLAN.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ Heuristic:
| C (C1) | DONE | Deep wave 1 complete (2026-06-05, Opus). Cluster C1 re-decomposed: *8 pure-integer kernels* staged under `proposals/idaptik/migrated/` (DeviceType, PuzzleFormat, PortNames, GameEvent, Kernel_Compute, Kernel, RetryPolicy, Diagnostics); 3 C1 files are host-side "senses" with no brain (Coprocessor_Backends, PortNamesCoprocessor, DLCLoader). *Four gates green:* 8/8 compile, 1223/1223 parity, 6 echo-boundary proofs (agda exit 0; 3 LOSSLESS + 3 CONTROLLED-LOSS), 8/8 assail-clean. Evidence: `migrated/EVIDENCE.adoc`. The per-shape recipe is now established (enum-taxonomy / status-gate / classifier / predicate). NEXT: wave 2 = C2.
| D (C2 wave 1) | DONE | Deep wave 2 (2026-06-05, Opus). Cluster C2 *wave 1* — the reversible VM value-transform opcodes — re-decomposed into *4 kernels* under `proposals/idaptik/migrated/` (VmArith, VmBitwise, VmAncilla, VmInstruction), covering 11 opcodes (Add/Sub/Negate/Noop/Swap/Flip/Xor/Rol/Ror/And/Or) + the 23-opcode taxonomy. Brain = the reversible scalar-int value transform per opcode; the register-name dict stays host-side. *Reversibility (`invert∘execute = id`) pinned as `*_roundtrip` exports.* *Four gates green:* 4/4 compile, 2100/2100 parity (incl. every round-trip, over i32 extremes), 1 echo-boundary LOSSLESS (23-opcode encoding, agda exit 0), 4/4 assail-clean. Evidence: `migrated/EVIDENCE-C2.adoc`. Surfaced 3 compiler facts: unary `~` codegen bug (workaround `-a-1`), arithmetic `>>` + no `>>>` (logical-shift-right emulated). NEXT: C2 wave 2.
| C6+C8 (fan-out) | DONE | Parallel deep wave (2026-06-05): two Sonnet agents migrated clusters C6 (combat/enemy) + C8 (device/network); parent re-verified + consolidated. *16 kernels* staged under `proposals/idaptik/migrated/` — C6: CombatFx, Detection, DifficultyScale, Distraction, DualAlert, HitboxGeom, PlayerHp, SecurityAi; C8: GlobalNetworkData, NetworkManager, SecurityRank, DeviceCaps, LaptopState, NetworkTransfer, PowerManager, CovertLink. *Four gates green (re-run by parent, not just agent-reported):* 16/16 compile, *34280/34280 parity* (C6 8185 + C8 26095, independent oracles), 7 echo-boundary LOSSLESS proofs, 16/16 assail-clean. Re-verification CAUGHT 3 PA-AFF-001 findings the agent reports missed (SecurityAi, SecurityRank, NetworkManager) — fixed with the established guard-helper clamp declaration; NetworkManager parity held at 2645/2645 after dropping the dead `Cat` enum, confirming semantics preserved. *4th compiler finding:* Float→wasm codegen broadly incomplete (pub-fn exports always type i32; float-literal operands mis-emit; `trunc()`/`float()` absent) — drives the `*Int.affine` parity subsets, keeps floats host-side. Evidence: `migrated/EVIDENCE-C6.adoc` + `EVIDENCE-C8.adoc`. NEXT: complete C7, then C2 wave 2.
| C7 | TODO | Player cluster: 8 `.affine` drafts staged (CriticalRoll, PlayerAttributes, QCertifications, SkillRank, SkillAbilities, QPrograms, JessicaLoadout, JessicaBackground) but the agent timed out *before* writing any parity config or evidence — *unverified, deliberately NOT landed*. Finish the 4 gates over the existing drafts (independent oracles → parity/boundary/assail), then stage. Drafts left untracked in the working tree as the head-start.
| C7 | DONE | Player cluster complete (2026-06-05): two scoped agents (5 verify + 3 float-re-decompose) + parent re-verification. *8 kernels* — CriticalRoll, PlayerAttributes, QCertifications, SkillRank, SkillAbilities, QPrograms, JessicaLoadout, JessicaBackground. *Four gates green (every gate re-run by parent):* 8/8 compile, *4348/4348 parity* (independent oracles), *6 echo-boundary LOSSLESS proofs* across 4 kernels (QCertifications, SkillRank, JessicaLoadout×3, JessicaBackground — agda re-typechecked by parent, exit 0); the other 4 transform/classifier kernels G3-n/a, 8/8 assail-clean. 3 kernels (CriticalRoll/PlayerAttributes/QPrograms) hit the Float→wasm wall (`min_float`/`max_float`/`trunc`) and were re-decomposed Int-native (milli-unit convention, floats host-side) per the C6 `*Int` pattern; parity caught + fixed an off-by-100 in PlayerAttributes. Evidence: `migrated/EVIDENCE-C7.adoc`. NEXT: C2 wave 2.
| C2b+ | TODO | C2 wave 2 (needs an array/linear-memory ABI): Mul/Div, the stack/memory opcodes (Push/Pop/Load/Store), control flow (Call/Loop/IfPos/IfZero), I/O (Send/Recv/CoprocessorCall), and the structural VM files (State, VM, SubroutineRegistry, *Coprocessor, bindings). Then C3..C12. The unary-`~` codegen bug is a candidate Phase-F compiler fix.
| F+ | TODO | Compiler walls (string backend, then effects).
| Ω | TODO (access-gated) | Cutover + ReScript extinction.
Expand Down
125 changes: 72 additions & 53 deletions proposals/idaptik/migrated/CriticalRoll/CriticalRoll.affine
Original file line number Diff line number Diff line change
@@ -1,75 +1,94 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// SPDX-FileCopyrightText: 2025-2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
//
// CriticalRoll -- the pure probability/threshold kernel re-decomposed from
// src/app/player/CriticalRoll.res. The ReScript original entangles three things:
// a non-deterministic draw (Math.random()), two attribute-driven threshold
// formulae for Jessica, and a three-way classification of the draw against a
// success and a failure threshold shared by Jessica and Q. Per the DESIGN-VISION
// ("AffineScript is the brain, JS/Pixi the senses; only primitives cross the wasm
// boundary"), neither the draw nor the variant labels are the unit of migration.
// What crosses is the deterministic arithmetic: the two thresholds and the band
// the comparison falls into. The host owns the entropy (Math.random), the rate
// tables QCertifications already serves, the rollResult record, and the outcome
// labels and colours. The kernel is stateless and total: every Float the boundary
// admits yields a defined Int verdict or Float threshold, never a trap.
// src/app/player/CriticalRollCoprocessor.res. The ReScript original entangles
// three things: a non-deterministic draw (Math.random()), two attribute-driven
// threshold formulae for Jessica, and a three-way classification of the draw
// against a success and a failure threshold shared by Jessica and Q. Per the
// DESIGN-VISION ("AffineScript is the brain, JS/Pixi the senses; only
// primitives cross the wasm boundary"), neither the draw nor the variant labels
// are the unit of migration. What crosses is the deterministic arithmetic: the
// two thresholds and the band the comparison falls into. The host owns the
// entropy (Math.random), the rate tables QCertifications already serves, the
// rollResult record, and the outcome labels and colours. The kernel is
// stateless and total: every Int the boundary admits yields a defined Int
// verdict or Int threshold, never a trap.
//
//## Why this split, not a port of jessicaRoll/qRoll
// jessicaRoll and qRoll each draw a random number, look thresholds up (Jessica by
// formula, Q by host rate table), then classify. The draw is impure and the Q
// lookups are discrete table reads with no arithmetic, so both stay host senses.
// The arithmetic that remains -- the two Jessica formulae and the one shared
// classification -- is what the co-processor owns. Both call sites feed the same
// classify_outcome with their respective thresholds, so the brain has one decision
// rule and the host supplies the two numbers however it sourced them.
// jessicaRoll and qRoll each draw a random number, look thresholds up (Jessica
// by formula, Q by host rate table), then classify. The draw is impure and the
// Q lookups are discrete table reads with no arithmetic, so both stay host
// senses. The arithmetic that remains -- the two Jessica formulae and the one
// shared classification -- is what the co-processor owns. Both call sites feed
// the same classify_outcome with their respective thresholds, so the brain has
// one decision rule and the host supplies the two numbers however it sourced
// them.
//
//## Outcome encoding (the header contract for the JS host)
// The rollOutcome variant collapses to an ordinal the host maps back to the
// variant and thence to a label/colour:
// 0 CriticalSuccess 1 Normal 2 CriticalFailure
// classify_outcome returns exactly one of these three. The boundaries match the
// ReScript verbatim: roll < critSuccess -> 0; else roll > 1 - critFail -> 2; else
// 1. The success test wins ties at the lower edge (strict <), the failure test at
// the upper edge (strict >), so a roll sitting exactly on a threshold is Normal,
// identical to the ReScript if/else chain.
// ReScript verbatim: roll < critSuccess -> 0; else roll > 1 - critFail -> 2;
// else 1. The success test wins ties at the lower edge (strict <), the failure
// test at the upper edge (strict >), so a roll sitting exactly on a threshold
// is Normal, identical to the ReScript if/else chain.
//
//## Float contract
// roll, crit_success and crit_fail cross as f64 (proven to marshal both ways;
// cf. migration/bindings/Maths.wasm and PlayerHP.wasm). roll is the host's draw in
// [0, 1); crit_success and crit_fail are probabilities in [0, 1]. The float
// comparisons drive an Int verdict, which the wasm backend compares as i32, the
// working subset for float-fed branches. jessica_crit_success and
// jessica_crit_fail return f64 thresholds the host either feeds straight back into
// classify_outcome or displays in the rollResult.
//## Int-native convention (MILLI-UNITS): the host pre-floors floats to ints
// All probabilities and roll values cross as MILLI-UNITS (×1000 of the Float):
// roll_milli = round(roll * 1000) e.g. 0.37 -> 370
// crit_success_milli returned from jessica_crit_success_milli
// crit_fail_milli returned from jessica_crit_fail_milli
//
// Game stats (primaryStat, wil) are integer-valued in the ReScript source
// (e.g. 80, 100, 150) and cross as raw Int -- NOT scaled. The division
// (stat - 100)/1000 in the original formulae has value < 1 and contributes
// only to the milli result: the integer formula below is exact.
//
// The original Float formulae and their milli-integer equivalents:
// jessica_crit_success: min(0.25, 0.05 + (stat-100)/1000.0)
// -> milli: min(250, 50 + (stat - 100)) [= min(250, stat - 50)]
// jessica_crit_fail: max(0.01, 0.10 + (100-wil)/1000.0)
// -> milli: max(10, 100 + (100 - wil)) [= max(10, 200 - wil)]
// classify_outcome: roll < crit_success / roll > 1 - crit_fail
// -> milli: roll_milli < cs_milli / roll_milli > (1000 - cf_milli)
//
// Arithmetic is identical to the ReScript on integer-valued stats (which are
// always whole numbers in game data). The host applies Math.round(x*1000) for
// the roll.

fn imin(a: Int, b: Int) -> Int {
if a < b { a } else { b }
}

fn imax(a: Int, b: Int) -> Int {
if a > b { a } else { b }
}

//## Jessica's critical-success threshold
// min(0.25, 0.05 + (primaryStat - 100)/1000), the jessicaRoll line verbatim in
// f64. Higher primary stat raises the chance; the cap at 0.25 is the design
// ceiling. primaryStat arrives as a Float, so the division is pure f64 and no
// int->float is implicated. Branchless via min_float rather than a Float if/else.
pub fn jessica_crit_success(primary_stat: Float) -> Float {
min_float(0.25, 0.05 + (primary_stat - 100.0) / 1000.0)
//## Jessica's critical-success threshold (milli-units)
// ReScript: min(0.25, 0.05 + (primaryStat - 100)/1000). In milli (stat is raw
// integer game stat): min(250, 50 + (stat - 100)). Cap at 250 (= 0.25).
pub fn jessica_crit_success_milli(stat: Int) -> Int {
imin(250, 50 + (stat - 100))
}

//## Jessica's critical-failure threshold
// max(0.01, 0.10 + (100 - wil)/1000), the jessicaRoll line verbatim in f64.
// Higher willpower lowers the chance; the floor at 0.01 is the design minimum.
// Branchless via max_float. wil is a Float host-side, so the subtract and divide
// are pure f64.
pub fn jessica_crit_fail(wil: Float) -> Float {
max_float(0.01, 0.10 + (100.0 - wil) / 1000.0)
//## Jessica's critical-failure threshold (milli-units)
// ReScript: max(0.01, 0.10 + (100 - wil)/1000). In milli: max(10, 100 + (100 -
// wil)) = max(10, 200 - wil). Floor at 10 (= 0.01).
pub fn jessica_crit_fail_milli(wil: Int) -> Int {
imax(10, 200 - wil)
}

//## Shared three-way classification
// The outcome if/else chain shared by jessicaRoll and qRoll, in isolation:
// if roll < crit_success -> CriticalSuccess (0)
// else if roll > 1.0 - crit_fail -> CriticalFailure (2)
// else -> Normal (1)
// Flat guarded returns rather than nested if/else; the parser dislikes deep
// nesting and the order of the guards preserves the ReScript precedence (success
// tested first, then failure, Normal as the fall-through). The strict comparisons
// if roll_milli < crit_success_milli -> CriticalSuccess (0)
// else if roll_milli > 1000 - crit_fail_milli -> CriticalFailure (2)
// else -> Normal (1)
// Flat guarded returns rather than nested if/else. The strict comparisons
// mean a roll exactly on a boundary is Normal, matching the ReScript.
pub fn classify_outcome(roll: Float, crit_success: Float, crit_fail: Float) -> Int {
if roll < crit_success { return 0; }
if roll > 1.0 - crit_fail { return 2; }
pub fn classify_outcome(roll_milli: Int, crit_success_milli: Int, crit_fail_milli: Int) -> Int {
if roll_milli < crit_success_milli { return 0; }
if roll_milli > (1000 - crit_fail_milli) { return 2; }
1
}
112 changes: 112 additions & 0 deletions proposals/idaptik/migrated/CriticalRoll/criticalroll.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// SPDX-FileCopyrightText: 2025-2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
//
// affine-parity config for CriticalRoll.affine (Int-native, milli-units).
// Oracles re-derive from CriticalRollCoprocessor.res semantics in plain JS.
//
// All probabilities and roll values are milli-units (×1000 of the original
// Float). Stats (primaryStat, wil) cross as raw integers.
//
// jessica_crit_success_milli: min(250, 50 + (stat - 100))
// ReScript equivalent: min(0.25, 0.05 + (stat-100)/1000.0) * 1000
// jessica_crit_fail_milli: max(10, 200 - wil)
// ReScript equivalent: max(0.01, 0.10 + (100-wil)/1000.0) * 1000
// classify_outcome(roll_milli, cs_milli, cf_milli):
// roll_milli < cs_milli -> 0, roll_milli > 1000-cf_milli -> 2, else 1

function jCritSuccessMilli(stat) {
// Original: min(0.25, 0.05 + (stat - 100) / 1000.0)
const v = 0.05 + (stat - 100) / 1000.0;
const clamped = Math.min(0.25, v);
return Math.round(clamped * 1000);
}

function jCritFailMilli(wil) {
// Original: max(0.01, 0.10 + (100 - wil) / 1000.0)
const v = 0.10 + (100 - wil) / 1000.0;
const clamped = Math.max(0.01, v);
return Math.round(clamped * 1000);
}

function classifyOutcome(rollMilli, csMilli, cfMilli) {
// Original: roll < critSuccess -> 0; roll > 1 - critFail -> 2; else 1
if (rollMilli < csMilli) return 0;
if (rollMilli > (1000 - cfMilli)) return 2;
return 1;
}

// Stat range: 0 to 200 (game stats). Roll range: 0..1000 milli.
const STAT_VALUES = [0, 50, 80, 100, 120, 150, 200];
const WIL_VALUES = [0, 50, 80, 100, 120, 150, 200];
const ROLL_VALUES = [0, 10, 50, 100, 200, 250, 499, 500, 501, 750, 900, 990, 1000];

export default {
affine: "CriticalRoll.affine",
compile: false,
cases: [
{
name: "jessica_crit_success_milli(stat 0..200): min(250, stat-50)",
export: "jessica_crit_success_milli",
args: [{ values: STAT_VALUES }],
oracle: (stat) => jCritSuccessMilli(stat),
},
{
name: "jessica_crit_fail_milli(wil 0..200): max(10, 200-wil)",
export: "jessica_crit_fail_milli",
args: [{ values: WIL_VALUES }],
oracle: (wil) => jCritFailMilli(wil),
},
{
name: "classify_outcome(roll_milli, cs_milli, cf_milli) stat=100",
export: "classify_outcome",
args: [
{ values: ROLL_VALUES },
{ values: [jCritSuccessMilli(100)] }, // 50 milli (stat=100 baseline)
{ values: [jCritFailMilli(100)] }, // 100 milli (wil=100 baseline)
],
oracle: (r, cs, cf) => classifyOutcome(r, cs, cf),
},
{
name: "classify_outcome boundary: roll exactly on cs threshold",
export: "classify_outcome",
args: [
// roll == crit_success_milli -> Normal (strict <)
{ values: [50, 51, 49] },
{ values: [50] },
{ values: [100] },
],
oracle: (r, cs, cf) => classifyOutcome(r, cs, cf),
},
{
name: "classify_outcome boundary: roll exactly on 1000-cf threshold",
export: "classify_outcome",
args: [
// cf=100, threshold = 1000-100=900; roll==900 -> Normal (strict >)
{ values: [899, 900, 901] },
{ values: [50] },
{ values: [100] },
],
oracle: (r, cs, cf) => classifyOutcome(r, cs, cf),
},
{
name: "classify_outcome wide sweep (stat=80, wil=150)",
export: "classify_outcome",
args: [
{ values: ROLL_VALUES },
{ values: [jCritSuccessMilli(80)] },
{ values: [jCritFailMilli(150)] },
],
oracle: (r, cs, cf) => classifyOutcome(r, cs, cf),
},
{
name: "classify_outcome wide sweep (stat=150, wil=50)",
export: "classify_outcome",
args: [
{ values: ROLL_VALUES },
{ values: [jCritSuccessMilli(150)] },
{ values: [jCritFailMilli(50)] },
],
oracle: (r, cs, cf) => classifyOutcome(r, cs, cf),
},
],
};
Loading
Loading