Physarum Native — Exploration Features (Plan 3) Implementation Plan¶
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add the four exploration features the web app doesn't have — Randomize, Mutate, Parameter-Locks, Undo/Redo, and Preset-Morph — to the native macOS/iPad/iPhone authoring editor, all driven by the existing ParamRegistry.
Architecture: All five features are pure additions on top of the already-shipping registry-driven editor. State lives on AppState (@Observable): a Set<ParamID> lock set and two [PresetParams] undo/redo stacks. Randomize/Mutate iterate ParamRegistry.all (45 scalar descriptors) plus a small explicit pass over the four non-scalar params (spawnPattern/blendMode/backgroundColor/species colors), skipping locked params and pushing one undo snapshot per action. A per-param RandomStrategy table (built from the 25 built-in presets' observed value bands) keeps randomized results in the "alive" region rather than the raw zod ranges. Morph is a pure value-type interpolation function. The live-render + 250 ms debounced respawn pipeline already exists in RenderCoordinator and needs no changes — every feature works by mutating state.edited, which already flows to the engine. Everything stays on the main thread (the §11 thread-safety guarantee is preserved by construction).
Tech Stack: Swift 5 / SwiftUI (@Observable), PhysarumEngine value types (PresetParams, SpeciesParams, RGB = SIMD3<Float>), XCTest in the hosted PhysarumAppTests target, XcodeGen (project.yml), built via xcodebuild.
Background / IST-state (verified 2026-06-03)¶
ParamID(Sources/Model/ParamRegistry.swift) is aHashableenum covering every editable param incl. species-indexed cases (moveSpeed(Int)…),matrix(Int,Int), and the non-scalarsspawnPattern/blendMode/backgroundColor/speciesColor(Int). It is directly usable as a lock-set key.ParamRegistry.all= 45 scalarParamDescriptors (id,label,section,range: ClosedRange<Double>,isInt,isRespawn,get,set,idKey). Non-scalars deliberately have no descriptor.AppStatehasedited/baseline: PresetParams, computedisDirtyvia the free functionparamsEqual(_:_:),reset(_:),resetAll(), and full custom-preset CRUD. No locks/undo/redo/randomize/mutate/morph exist yet.ParamSlideralready does tap-to-type + double-tap reset; it uses a continuousSlider(value:in:)with noonEditingChangedhook and no lock affordance.RenderCoordinator.setParams(_:)already live-updates the sim every frame and fires a 250 ms debounced respawn whenagentCount/spawnPattern/anypopulationSharechanges. Do not modify it.- Tests live in the hosted macOS XCTest target
PhysarumAppTests(Tests/), patternimport XCTest / @testable import Physarum / import PhysarumEngine, seed viaPresetCatalog.fallbackPreset.params. New.swiftfiles underTests/are auto-included afterxcodegen generate.
Build & test commands (used in every task)¶
- Regenerate project after adding files:
cd apple/PhysarumApp && xcodegen generate - Run app test suite (single test):
xcodebuild test -scheme Physarum -destination 'platform=macOS' -only-testing:PhysarumAppTests/<Class>/<method> 2>&1 | tail -30 - Run whole app suite:
xcodebuild test -scheme Physarum -destination 'platform=macOS' 2>&1 | tail -40 - Build only:
xcodebuild -scheme Physarum -destination 'platform=macOS' build 2>&1 | tail -20 - Engine suite (unchanged, for sanity):
cd apple/PhysarumEngine && swift test
NOTE on int round-trips: int descriptors round in their
set. When asserting a sampled/morphed value against an int descriptor useaccuracy: 0.5(asParamRegistryTestsdoes).PresetParamsis notEquatable— compare viaparamsEqual(_:_:)or per-descriptorget.
File Structure¶
Create:
- Sources/Model/RandomStrategy.swift — RandomStrategy enum + sample(using:) + span + RandomizeProfiles.strategy(for:) table.
- Sources/Model/PresetMorph.swift — morphParams(_:_:t:) pure function + mixRGB + hsv2rgb color helpers shared with randomize.
- Sources/Model/AppState+Exploration.swift — AppState extension: locks, undo/redo, randomize, mutate.
- Sources/Editor/MorphSheet.swift — the Morph UI (preset-B picker + slider + Apply/Cancel).
- Sources/UI/LockButton.swift — reusable 🔒 toggle bound to a ParamID.
- Tests/TestSupport.swift — SeededGenerator (deterministic RandomNumberGenerator) for reproducible randomize/mutate tests.
- Tests/UndoRedoTests.swift, Tests/LocksTests.swift, Tests/RandomizeTests.swift, Tests/MutateTests.swift, Tests/MorphTests.swift.
Modify:
- Sources/Editor/ParamSlider.swift — add onEditingChanged undo hook + a trailing lock toggle.
- Sources/Editor/Sections.swift — add lock toggles to the non-scalar controls (spawn/blend pickers, species + background color).
- Sources/Editor/EditorView.swift — add the exploration toolbar (Randomize · Mutate · Undo · Redo · Morph) + ⌘Z/⌘⇧Z shortcuts.
Do NOT modify: RenderCoordinator.swift, MetalView.swift, ParamRegistry.swift (the ParamID/descriptor model is already complete), project.yml (folder-sourced; just regenerate).
Task 1: Undo/Redo on AppState¶
Files:
- Create: apple/PhysarumApp/Sources/Model/AppState+Exploration.swift
- Create: apple/PhysarumApp/Tests/UndoRedoTests.swift
- Modify: apple/PhysarumApp/Sources/Model/AppState.swift (add stored stacks; clear on select)
- [ ] Step 1: Add the stacks + lock set storage to AppState
In AppState.swift, add these stored properties right after var autoCycle: Bool = false (line 12):
In select(id:) (after the baseline = p.params line, inside the function), clear history so undo never crosses a preset boundary:
- [ ] Step 2: Write the failing test
Create apple/PhysarumApp/Tests/UndoRedoTests.swift:
import XCTest
@testable import Physarum
import PhysarumEngine
final class UndoRedoTests: XCTestCase {
private func moveSpeed0() -> ParamDescriptor {
ParamRegistry.all.first { $0.id == .moveSpeed(0) }!
}
func testPushUndoThenUndoRestoresPreviousValue() {
let state = AppState()
let d = moveSpeed0()
let original = d.get(state.edited)
state.pushUndo() // snapshot pre-edit
d.set(&state.edited, original + 0.5)
XCTAssertEqual(d.get(state.edited), original + 0.5, accuracy: 1e-4)
XCTAssertTrue(state.canUndo)
state.undoEdit()
XCTAssertEqual(d.get(state.edited), original, accuracy: 1e-4)
}
func testRedoReappliesUndoneEdit() {
let state = AppState()
let d = moveSpeed0()
let original = d.get(state.edited)
state.pushUndo()
d.set(&state.edited, original + 0.5)
state.undoEdit()
XCTAssertTrue(state.canRedo)
state.redoEdit()
XCTAssertEqual(d.get(state.edited), original + 0.5, accuracy: 1e-4)
}
func testNewEditAfterUndoInvalidatesRedo() {
let state = AppState()
let d = moveSpeed0()
let base = d.get(state.edited)
state.pushUndo(); d.set(&state.edited, base + 0.5)
state.undoEdit()
XCTAssertTrue(state.canRedo)
state.pushUndo() // a new committed edit
d.set(&state.edited, base + 0.9)
XCTAssertFalse(state.canRedo, "redo must be cleared after a new edit")
}
func testUndoStackIsCapped() {
let state = AppState()
for _ in 0..<80 { state.pushUndo() }
XCTAssertLessThanOrEqual(state.undoStack.count, 50)
}
func testSelectingPresetClearsHistory() {
let state = AppState()
state.pushUndo()
XCTAssertTrue(state.canUndo)
// select a different preset than the current one
let other = state.presets.first { $0.id != state.selectedID }!
state.select(id: other.id)
XCTAssertFalse(state.canUndo)
XCTAssertFalse(state.canRedo)
}
}
- [ ] Step 3: Run to verify it fails
Run: cd apple/PhysarumApp && xcodegen generate && xcodebuild test -scheme Physarum -destination 'platform=macOS' -only-testing:PhysarumAppTests/UndoRedoTests 2>&1 | tail -30
Expected: compile failure — pushUndo/undoEdit/redoEdit/canUndo/canRedo not found.
- [ ] Step 4: Implement the undo/redo API
Create apple/PhysarumApp/Sources/Model/AppState+Exploration.swift:
import SwiftUI
import PhysarumEngine
extension AppState {
// MARK: - Undo / Redo
private static let undoCap = 50
var canUndo: Bool { !undoStack.isEmpty }
var canRedo: Bool { !redoStack.isEmpty }
/// Record a restore point. Call BEFORE a committed mutation (slider gesture
/// start, tap-to-type, reset, randomize, mutate, morph-apply). Pass an explicit
/// snapshot when the mutation has already happened (e.g. morph preview).
func pushUndo(_ snapshot: PresetParams? = nil) {
undoStack.append(snapshot ?? edited)
if undoStack.count > Self.undoCap { undoStack.removeFirst() }
redoStack.removeAll()
}
func undoEdit() {
guard let prev = undoStack.popLast() else { return }
redoStack.append(edited)
edited = prev
}
func redoEdit() {
guard let next = redoStack.popLast() else { return }
undoStack.append(edited)
edited = next
}
}
- [ ] Step 5: Run to verify it passes
Run: xcodebuild test -scheme Physarum -destination 'platform=macOS' -only-testing:PhysarumAppTests/UndoRedoTests 2>&1 | tail -30
Expected: PASS (5 tests).
- [ ] Step 6: Commit
cd "$(git rev-parse --show-toplevel)"
git add apple/PhysarumApp/Sources/Model/AppState.swift apple/PhysarumApp/Sources/Model/AppState+Exploration.swift apple/PhysarumApp/Tests/UndoRedoTests.swift
git commit -m "feat(app): undo/redo snapshot stacks on AppState"
Task 2: Parameter Locks on AppState¶
Files:
- Modify: apple/PhysarumApp/Sources/Model/AppState+Exploration.swift
- Create: apple/PhysarumApp/Tests/LocksTests.swift
- [ ] Step 1: Write the failing test
Create apple/PhysarumApp/Tests/LocksTests.swift:
import XCTest
@testable import Physarum
import PhysarumEngine
final class LocksTests: XCTestCase {
func testToggleAddsAndRemoves() {
let state = AppState()
XCTAssertFalse(state.isLocked(.moveSpeed(0)))
state.toggleLock(.moveSpeed(0))
XCTAssertTrue(state.isLocked(.moveSpeed(0)))
state.toggleLock(.moveSpeed(0))
XCTAssertFalse(state.isLocked(.moveSpeed(0)))
}
func testDistinctSpeciesIndicesLockIndependently() {
let state = AppState()
state.toggleLock(.moveSpeed(0))
XCTAssertTrue(state.isLocked(.moveSpeed(0)))
XCTAssertFalse(state.isLocked(.moveSpeed(1)))
XCTAssertFalse(state.isLocked(.moveSpeed(2)))
}
func testNonScalarParamsAreLockable() {
let state = AppState()
state.toggleLock(.spawnPattern)
state.toggleLock(.backgroundColor)
XCTAssertTrue(state.isLocked(.spawnPattern))
XCTAssertTrue(state.isLocked(.backgroundColor))
}
}
- [ ] Step 2: Run to verify it fails
Run: xcodebuild test -scheme Physarum -destination 'platform=macOS' -only-testing:PhysarumAppTests/LocksTests 2>&1 | tail -20
Expected: compile failure — isLocked/toggleLock not found.
- [ ] Step 3: Implement the lock API
Append to the extension AppState in AppState+Exploration.swift (before the closing brace):
// MARK: - Locks
func isLocked(_ id: ParamID) -> Bool { locked.contains(id) }
func toggleLock(_ id: ParamID) {
if locked.contains(id) { locked.remove(id) } else { locked.insert(id) }
}
- [ ] Step 4: Run to verify it passes
Run: xcodebuild test -scheme Physarum -destination 'platform=macOS' -only-testing:PhysarumAppTests/LocksTests 2>&1 | tail -20
Expected: PASS (3 tests).
- [ ] Step 5: Commit
cd "$(git rev-parse --show-toplevel)"
git add apple/PhysarumApp/Sources/Model/AppState+Exploration.swift apple/PhysarumApp/Tests/LocksTests.swift
git commit -m "feat(app): parameter lock set (Set<ParamID>) on AppState"
Task 3: RandomStrategy table (safe per-param sampling bands)¶
Files:
- Create: apple/PhysarumApp/Sources/Model/RandomStrategy.swift
- Create: apple/PhysarumApp/Tests/TestSupport.swift
- Create: apple/PhysarumApp/Tests/RandomizeTests.swift (profile-coverage tests; randomize() itself comes in Task 4)
Per-param bands are derived from the 25 built-in presets' observed value distributions (not the raw zod ranges, which produce dead/blown-out fields). mostlyZero models "accent" params that are off in most presets; bimodal models agentCount (sparse ~6–14k vs dense ~100–180k clusters).
- [ ] Step 1: Implement RandomStrategy + the profile table
Create apple/PhysarumApp/Sources/Model/RandomStrategy.swift:
import Foundation
/// How Randomize samples a single scalar param. Bands come from the observed
/// value ranges of the 25 built-in presets, kept inside the "alive" region.
enum RandomStrategy {
case uniform(ClosedRange<Double>)
/// Emit 0 with probability `zeroProb`, else uniform in `band` (off-by-default accents).
case mostlyZero(zeroProb: Double, ClosedRange<Double>)
/// Pick band A with probability `probA`, else band B, then uniform inside it.
case bimodal(probA: Double, ClosedRange<Double>, ClosedRange<Double>)
func sample<G: RandomNumberGenerator>(using g: inout G) -> Double {
switch self {
case let .uniform(r):
return Double.random(in: r, using: &g)
case let .mostlyZero(p, r):
return Double.random(in: 0...1, using: &g) < p ? 0 : Double.random(in: r, using: &g)
case let .bimodal(p, a, b):
let r = Double.random(in: 0...1, using: &g) < p ? a : b
return Double.random(in: r, using: &g)
}
}
/// Representative band used for Mutate magnitude + clamping (keeps mutate inside the lively zone).
var span: ClosedRange<Double> {
switch self {
case let .uniform(r): return r
case let .mostlyZero(_, r): return 0...r.upperBound
case let .bimodal(_, a, b): return min(a.lowerBound, b.lowerBound)...max(a.upperBound, b.upperBound)
}
}
}
enum RandomizeProfiles {
/// Safe sampling strategy for every scalar ParamID. Species index and matrix
/// position are collapsed (all species share a profile; matrix diagonal vs
/// off-diagonal differ).
static func strategy(for id: ParamID) -> RandomStrategy {
switch id {
case .agentCount: return .bimodal(probA: 0.8, 6_000...14_000, 100_000...180_000)
case .exposure: return .uniform(1.5...3.6)
case .simSpeed: return .uniform(1...1) // time knob, not a look knob → keep at 1
case .decayRate: return .uniform(0.94...0.99)
case .diffusionFreq: return .uniform(1...4)
case .fadeStrength: return .uniform(0.03...0.12)
case .trailSaturation: return .uniform(110...255)
case .chaosInterval: return .mostlyZero(zeroProb: 0.7, 20...120)
case .chaosStrength: return .mostlyZero(zeroProb: 0.7, 0.1...0.6)
case .rhythmAmplitude: return .uniform(0.05...0.2)
case .headIntensity: return .mostlyZero(zeroProb: 0.6, 0.3...1.2)
case .kaleidoscope: return .mostlyZero(zeroProb: 0.8, 4...6)
case .feedbackAmount: return .uniform(0...0.6)
case .bloomThreshold: return .uniform(0.15...1.3)
case .bloomIntensity: return .uniform(0.3...2.4)
case .vignette: return .uniform(0...0.6)
case .saturation: return .uniform(0.6...1.5)
case .contrast: return .uniform(0.85...1.4)
case .populationShare: return .uniform(0.1...1)
case .sensorAngle: return .uniform(0.3...0.95)
case .sensorDistance: return .uniform(12...40)
case .turnSpeed: return .uniform(0.2...0.55)
case .moveSpeed: return .uniform(0.5...1.9)
case .depositAmount: return .uniform(4...13)
case let .matrix(r, c): return .uniform(r == c ? 0.5...1.5 : -0.6...0.6)
// non-scalars are not sampled here (handled explicitly in randomize())
case .spawnPattern, .blendMode, .backgroundColor, .speciesColor:
return .uniform(0...0)
}
}
}
NOTE: the enum cases with associated values are matched ignoring the value via
case .populationShare:etc. — Swift allows omitting the binding pattern when you don't use it. If the compiler rejects that, change tocase .populationShare(_):.
- [ ] Step 2: Add the seeded RNG test helper
Create apple/PhysarumApp/Tests/TestSupport.swift:
import Foundation
/// Deterministic SplitMix64 RNG so randomize/mutate tests are reproducible.
struct SeededGenerator: RandomNumberGenerator {
private var state: UInt64
init(seed: UInt64) { state = seed }
mutating func next() -> UInt64 {
state = state &+ 0x9E37_79B9_7F4A_7C15
var z = state
z = (z ^ (z >> 30)) &* 0xBF58_476D_1CE4_E5B9
z = (z ^ (z >> 27)) &* 0x94D0_49BB_1331_11EB
return z ^ (z >> 31)
}
}
- [ ] Step 3: Write the failing profile-coverage test
Create apple/PhysarumApp/Tests/RandomizeTests.swift:
import XCTest
@testable import Physarum
import PhysarumEngine
final class RandomizeTests: XCTestCase {
func testEveryScalarStrategySamplesWithinDescriptorRange() {
var g = SeededGenerator(seed: 42)
for d in ParamRegistry.all {
let strat = RandomizeProfiles.strategy(for: d.id)
for _ in 0..<200 {
let v = strat.sample(using: &g)
XCTAssertGreaterThanOrEqual(v, d.range.lowerBound - 1e-9, "\(d.idKey) below range")
XCTAssertLessThanOrEqual(v, d.range.upperBound + 1e-9, "\(d.idKey) above range")
}
}
}
func testMostlyZeroEmitsZeroAndNonZero() {
var g = SeededGenerator(seed: 7)
let strat = RandomStrategy.mostlyZero(zeroProb: 0.7, 0.1...0.6)
var zeros = 0, nonzeros = 0
for _ in 0..<500 { strat.sample(using: &g) == 0 ? (zeros += 1) : (nonzeros += 1) }
XCTAssertGreaterThan(zeros, 0)
XCTAssertGreaterThan(nonzeros, 0)
}
}
- [ ] Step 4: Run to verify it passes (the impl from Step 1 already satisfies these)
Run: cd apple/PhysarumApp && xcodegen generate && xcodebuild test -scheme Physarum -destination 'platform=macOS' -only-testing:PhysarumAppTests/RandomizeTests 2>&1 | tail -30
Expected: PASS (2 tests). If testEveryScalarStrategySamplesWithinDescriptorRange fails for a param, the band exceeds its descriptor range — tighten the band in the table.
- [ ] Step 5: Commit
cd "$(git rev-parse --show-toplevel)"
git add apple/PhysarumApp/Sources/Model/RandomStrategy.swift apple/PhysarumApp/Tests/TestSupport.swift apple/PhysarumApp/Tests/RandomizeTests.swift
git commit -m "feat(app): RandomStrategy table — safe per-param sampling bands"
Task 4: randomize() on AppState¶
Files:
- Modify: apple/PhysarumApp/Sources/Model/AppState+Exploration.swift
- Create: apple/PhysarumApp/Sources/Model/PresetMorph.swift (color helpers hsv2rgb/mixRGB live here; used by randomize now and morph in Task 6)
- Modify: apple/PhysarumApp/Tests/RandomizeTests.swift
- [ ] Step 1: Add the color helpers
Create apple/PhysarumApp/Sources/Model/PresetMorph.swift (morph function added in Task 6; color helpers first):
import simd
import PhysarumEngine
/// HSV (each 0…1) → engine RGB.
func hsv2rgb(_ h: Double, _ s: Double, _ v: Double) -> RGB {
let i = Int(h * 6) % 6
let f = h * 6 - Double(Int(h * 6))
let p = v * (1 - s), q = v * (1 - f * s), t = v * (1 - (1 - f) * s)
let c: (Double, Double, Double)
switch i {
case 0: c = (v, t, p)
case 1: c = (q, v, p)
case 2: c = (p, v, t)
case 3: c = (p, q, v)
case 4: c = (t, p, v)
default: c = (v, p, q)
}
return RGB(Float(c.0), Float(c.1), Float(c.2))
}
/// Component-wise linear interpolation of two RGB colors.
func mixRGB(_ a: RGB, _ b: RGB, _ t: Double) -> RGB {
a + (b - a) * Float(t)
}
- [ ] Step 2: Write the failing test
Append to RandomizeTests.swift (inside the class):
func testRandomizeRespectsLocks() {
let state = AppState()
let d = ParamRegistry.all.first { $0.id == .moveSpeed(0) }!
state.toggleLock(.moveSpeed(0))
let locked = d.get(state.edited)
var g = SeededGenerator(seed: 99)
state.randomize(using: &g)
XCTAssertEqual(d.get(state.edited), locked, accuracy: 1e-4, "locked param must not change")
}
func testRandomizeKeepsAllScalarsInRange() {
let state = AppState()
var g = SeededGenerator(seed: 3)
state.randomize(using: &g)
for d in ParamRegistry.all {
let v = d.get(state.edited)
XCTAssertGreaterThanOrEqual(v, d.range.lowerBound - 0.5, "\(d.idKey)")
XCTAssertLessThanOrEqual(v, d.range.upperBound + 0.5, "\(d.idKey)")
}
}
func testRandomizePushesOneUndoSnapshot() {
let state = AppState()
let before = state.undoStack.count
var g = SeededGenerator(seed: 5)
state.randomize(using: &g)
XCTAssertEqual(state.undoStack.count, before + 1)
// undo returns to the pre-randomize state
let preMove = ParamRegistry.all.first { $0.id == .moveSpeed(0) }!.get(state.baseline)
state.undoEdit()
XCTAssertEqual(ParamRegistry.all.first { $0.id == .moveSpeed(0) }!.get(state.edited),
preMove, accuracy: 1e-4)
}
func testRandomizeIsDeterministicForSameSeed() {
let a = AppState(); var g1 = SeededGenerator(seed: 1234); a.randomize(using: &g1)
let b = AppState(); var g2 = SeededGenerator(seed: 1234); b.randomize(using: &g2)
XCTAssertTrue(paramsEqual(a.edited, b.edited), "same seed → same result")
}
- [ ] Step 3: Run to verify it fails
Run: xcodebuild test -scheme Physarum -destination 'platform=macOS' -only-testing:PhysarumAppTests/RandomizeTests 2>&1 | tail -30
Expected: compile failure — randomize(using:) not found.
- [ ] Step 4: Implement randomize()
Append to the extension AppState in AppState+Exploration.swift:
// MARK: - Randomize
/// Replace every non-locked param with a fresh value sampled from its safe band.
/// Scalars come from `RandomizeProfiles`; non-scalars (enums/colors) get bespoke,
/// taste-tuned randomization. Pushes one undo snapshot.
func randomize<G: RandomNumberGenerator>(using g: inout G) {
pushUndo()
var p = edited
for d in ParamRegistry.all where !isLocked(d.id) {
d.set(&p, RandomizeProfiles.strategy(for: d.id).sample(using: &g))
}
randomizeNonScalars(into: &p, using: &g)
edited = p
}
/// Convenience entry point for the UI (system RNG).
func randomize() {
var g = SystemRandomNumberGenerator()
randomize(using: &g)
}
private func randomizeNonScalars<G: RandomNumberGenerator>(into p: inout PresetParams, using g: inout G) {
if !isLocked(.spawnPattern) {
let r = Double.random(in: 0...1, using: &g)
p.spawnPattern = r < 0.6 ? .random : (r < 0.8 ? .clusters : (r < 0.92 ? .center : .ring))
}
if !isLocked(.blendMode) {
p.blendMode = Double.random(in: 0...1, using: &g) < 0.85 ? .additive : .paint
}
for i in 0..<3 where !isLocked(.speciesColor(i)) {
// vivid: random hue, high saturation/value so trails read against a dark field
p.species[i].color = hsv2rgb(Double.random(in: 0...1, using: &g),
Double.random(in: 0.6...1, using: &g),
Double.random(in: 0.7...1, using: &g))
}
if !isLocked(.backgroundColor) {
// keep luminance very low, vary hue subtly
p.backgroundColor = hsv2rgb(Double.random(in: 0...1, using: &g),
Double.random(in: 0...0.4, using: &g),
Double.random(in: 0...0.06, using: &g))
}
}
- [ ] Step 5: Run to verify it passes
Run: xcodebuild test -scheme Physarum -destination 'platform=macOS' -only-testing:PhysarumAppTests/RandomizeTests 2>&1 | tail -30
Expected: PASS (6 tests total in the class).
- [ ] Step 6: Commit
cd "$(git rev-parse --show-toplevel)"
git add apple/PhysarumApp/Sources/Model/AppState+Exploration.swift apple/PhysarumApp/Sources/Model/PresetMorph.swift apple/PhysarumApp/Tests/RandomizeTests.swift
git commit -m "feat(app): randomize() — lock-aware, safe-band, deterministic"
Task 5: mutate() on AppState¶
Files:
- Modify: apple/PhysarumApp/Sources/Model/AppState+Exploration.swift
- Create: apple/PhysarumApp/Tests/MutateTests.swift
Mutate = "search nearby": perturb each non-locked scalar by ±strength × its safe-band width, clamped to the safe band. Discrete enums are left alone (switching spawnPattern isn't "nearby"); colors get a small nudge.
- [ ] Step 1: Write the failing test
Create apple/PhysarumApp/Tests/MutateTests.swift:
import XCTest
@testable import Physarum
import PhysarumEngine
final class MutateTests: XCTestCase {
func testMutateStaysWithinDescriptorRange() {
let state = AppState()
var g = SeededGenerator(seed: 11)
for _ in 0..<20 { state.mutate(strength: 0.15, using: &g) }
for d in ParamRegistry.all {
let v = d.get(state.edited)
XCTAssertGreaterThanOrEqual(v, d.range.lowerBound - 0.5, "\(d.idKey)")
XCTAssertLessThanOrEqual(v, d.range.upperBound + 0.5, "\(d.idKey)")
}
}
func testMutateRespectsLocks() {
let state = AppState()
state.toggleLock(.sensorDistance(1))
let d = ParamRegistry.all.first { $0.id == .sensorDistance(1) }!
let locked = d.get(state.edited)
var g = SeededGenerator(seed: 22)
state.mutate(strength: 0.3, using: &g)
XCTAssertEqual(d.get(state.edited), locked, accuracy: 1e-4)
}
func testMutateLeavesDiscreteEnumsUnchanged() {
let state = AppState()
let spawn = state.edited.spawnPattern
let blend = state.edited.blendMode
var g = SeededGenerator(seed: 33)
state.mutate(strength: 0.5, using: &g)
XCTAssertEqual(state.edited.spawnPattern, spawn)
XCTAssertEqual(state.edited.blendMode, blend)
}
func testMutatePushesUndo() {
let state = AppState()
let before = state.undoStack.count
var g = SeededGenerator(seed: 44)
state.mutate(using: &g)
XCTAssertEqual(state.undoStack.count, before + 1)
}
}
- [ ] Step 2: Run to verify it fails
Run: cd apple/PhysarumApp && xcodegen generate && xcodebuild test -scheme Physarum -destination 'platform=macOS' -only-testing:PhysarumAppTests/MutateTests 2>&1 | tail -30
Expected: compile failure — mutate not found.
- [ ] Step 3: Implement mutate()
Append to the extension AppState in AppState+Exploration.swift:
// MARK: - Mutate
/// Perturb every non-locked scalar by ±strength × its safe-band width, clamped
/// to the safe band. Discrete enums are left as-is; colors get a small nudge.
func mutate<G: RandomNumberGenerator>(strength: Double = 0.15, using g: inout G) {
pushUndo()
var p = edited
for d in ParamRegistry.all where !isLocked(d.id) {
let band = RandomizeProfiles.strategy(for: d.id).span
let width = band.upperBound - band.lowerBound
guard width > 0 else { continue } // simSpeed (1...1) etc. — no-op
let delta = Double.random(in: -1...1, using: &g) * strength * width
let v = min(max(d.get(p) + delta, band.lowerBound), band.upperBound)
d.set(&p, v)
}
// small color nudge (hue drift) for variety without jumping palettes
for i in 0..<3 where !isLocked(.speciesColor(i)) {
p.species[i].color = nudgeColor(p.species[i].color, strength: strength, using: &g)
}
edited = p
}
func mutate(strength: Double = 0.15) {
var g = SystemRandomNumberGenerator()
mutate(strength: strength, using: &g)
}
private func nudgeColor<G: RandomNumberGenerator>(_ c: RGB, strength: Double, using g: inout G) -> RGB {
let d = Float(strength) * 0.3
func j(_ x: Float) -> Float { min(max(x + Float.random(in: -d...d, using: &g), 0), 1) }
return RGB(j(c.x), j(c.y), j(c.z))
}
- [ ] Step 4: Run to verify it passes
Run: xcodebuild test -scheme Physarum -destination 'platform=macOS' -only-testing:PhysarumAppTests/MutateTests 2>&1 | tail -30
Expected: PASS (4 tests).
- [ ] Step 5: Commit
cd "$(git rev-parse --show-toplevel)"
git add apple/PhysarumApp/Sources/Model/AppState+Exploration.swift apple/PhysarumApp/Tests/MutateTests.swift
git commit -m "feat(app): mutate() — bounded nearby perturbation"
Task 6: morphParams() pure interpolation¶
Files:
- Modify: apple/PhysarumApp/Sources/Model/PresetMorph.swift
- Create: apple/PhysarumApp/Tests/MorphTests.swift
- [ ] Step 1: Write the failing test
Create apple/PhysarumApp/Tests/MorphTests.swift:
import XCTest
@testable import Physarum
import PhysarumEngine
final class MorphTests: XCTestCase {
private func presetA() -> PresetParams { PresetCatalog.fallbackPreset.params }
private func presetB() -> PresetParams {
var p = PresetCatalog.fallbackPreset.params
for d in ParamRegistry.all { d.set(&p, d.range.upperBound) } // distinct from A
p.spawnPattern = (p.spawnPattern == .ring) ? .random : .ring
p.blendMode = (p.blendMode == .paint) ? .additive : .paint
return p
}
func testEndpointsAreExact() {
let a = presetA(), b = presetB()
XCTAssertTrue(paramsEqual(morphParams(a, b, t: 0), a), "t=0 → A")
XCTAssertTrue(paramsEqual(morphParams(a, b, t: 1), b), "t=1 → B")
}
func testScalarMidpointIsHalfway() {
let a = presetA(), b = presetB()
let mid = morphParams(a, b, t: 0.5)
let d = ParamRegistry.all.first { $0.id == .moveSpeed(0) }!
XCTAssertEqual(d.get(mid), (d.get(a) + d.get(b)) / 2, accuracy: 1e-4)
}
func testDiscreteSwitchesAtHalf() {
let a = presetA(), b = presetB()
XCTAssertEqual(morphParams(a, b, t: 0.49).spawnPattern, a.spawnPattern)
XCTAssertEqual(morphParams(a, b, t: 0.5).spawnPattern, b.spawnPattern)
XCTAssertEqual(morphParams(a, b, t: 0.49).blendMode, a.blendMode)
XCTAssertEqual(morphParams(a, b, t: 0.5).blendMode, b.blendMode)
}
func testColorInterpolates() {
var a = presetA(); a.species[0].color = RGB(0, 0, 0)
var b = presetB(); b.species[0].color = RGB(1, 1, 1)
let mid = morphParams(a, b, t: 0.5)
XCTAssertEqual(mid.species[0].color.x, 0.5, accuracy: 1e-4)
}
func testClampsOutsideUnit() {
let a = presetA(), b = presetB()
XCTAssertTrue(paramsEqual(morphParams(a, b, t: -0.2), a))
XCTAssertTrue(paramsEqual(morphParams(a, b, t: 1.7), b))
}
}
- [ ] Step 2: Run to verify it fails
Run: cd apple/PhysarumApp && xcodegen generate && xcodebuild test -scheme Physarum -destination 'platform=macOS' -only-testing:PhysarumAppTests/MorphTests 2>&1 | tail -30
Expected: compile failure — morphParams not found.
- [ ] Step 3: Implement morphParams()
Append to apple/PhysarumApp/Sources/Model/PresetMorph.swift:
/// Interpolate two presets at t∈[0,1]. Scalars (incl. matrix, agentCount) lerp via
/// the registry; colors lerp component-wise; discrete enums switch at the 0.5
/// threshold. Endpoints are exact (t≤0 → a, t≥1 → b).
func morphParams(_ a: PresetParams, _ b: PresetParams, t: Double) -> PresetParams {
if t <= 0 { return a }
if t >= 1 { return b }
var out = a
for d in ParamRegistry.all {
let va = d.get(a), vb = d.get(b)
d.set(&out, va + (vb - va) * t) // set() rounds ints
}
for i in 0..<3 {
out.species[i].color = mixRGB(a.species[i].color, b.species[i].color, t)
}
out.backgroundColor = mixRGB(a.backgroundColor, b.backgroundColor, t)
out.spawnPattern = t < 0.5 ? a.spawnPattern : b.spawnPattern
out.blendMode = t < 0.5 ? a.blendMode : b.blendMode
return out
}
- [ ] Step 4: Run to verify it passes
Run: xcodebuild test -scheme Physarum -destination 'platform=macOS' -only-testing:PhysarumAppTests/MorphTests 2>&1 | tail -30
Expected: PASS (5 tests).
- [ ] Step 5: Commit
cd "$(git rev-parse --show-toplevel)"
git add apple/PhysarumApp/Sources/Model/PresetMorph.swift apple/PhysarumApp/Tests/MorphTests.swift
git commit -m "feat(app): morphParams() — registry-driven preset interpolation"
Task 7: ParamSlider — undo-on-edit hook + lock toggle¶
Files:
- Create: apple/PhysarumApp/Sources/UI/LockButton.swift
- Modify: apple/PhysarumApp/Sources/Editor/ParamSlider.swift
UI task — verified by build + manual check (no unit test; SwiftUI views aren't unit-tested in this project).
- [ ] Step 1: Create the reusable lock toggle
Create apple/PhysarumApp/Sources/UI/LockButton.swift:
import SwiftUI
/// A small 🔒 toggle bound to one ParamID. Locked params are skipped by Randomize/Mutate.
struct LockButton: View {
@Bindable var state: AppState
let id: ParamID
var body: some View {
Button { state.toggleLock(id) } label: {
Image(systemName: state.isLocked(id) ? "lock.fill" : "lock.open")
.font(.system(size: 10))
.foregroundStyle(state.isLocked(id) ? Theme.Colors.accent : Theme.Colors.fgTertiary)
}
.buttonStyle(.plain)
.help(state.isLocked(id) ? "Unlock (randomize will change it)" : "Lock (randomize will keep it)")
}
}
- [ ] Step 2: Add the lock toggle + undo hook to ParamSlider
In ParamSlider.swift, replace the HStack { … valueLabel } and the Slider(...) block (lines 14–24) with:
HStack {
Text(descriptor.label)
.font(Theme.Fonts.label)
.foregroundStyle(Theme.Colors.fgSecondary)
Spacer()
LockButton(state: state, id: descriptor.id)
valueLabel
}
Slider(value: Binding(get: { value }, set: { commit($0) }),
in: descriptor.range,
onEditingChanged: { editing in if editing { state.pushUndo() } })
.tint(Theme.Colors.accent)
.controlSize(.small)
.help(descriptor.label)
In the same file, make the double-tap reset record undo first — replace line 27:
And make tap-to-type record undo — in commitText() replace its body:
private func commitText() {
if let t = editingText, let v = Double(t) { state.pushUndo(); commit(v) }
editingText = nil
}
- [ ] Step 3: Build to verify it compiles
Run: cd apple/PhysarumApp && xcodegen generate && xcodebuild -scheme Physarum -destination 'platform=macOS' build 2>&1 | tail -20
Expected: BUILD SUCCEEDED.
- [ ] Step 4: Re-run the full suite (no regression)
Run: xcodebuild test -scheme Physarum -destination 'platform=macOS' 2>&1 | tail -20
Expected: all tests PASS.
- [ ] Step 5: Commit
cd "$(git rev-parse --show-toplevel)"
git add apple/PhysarumApp/Sources/UI/LockButton.swift apple/PhysarumApp/Sources/Editor/ParamSlider.swift
git commit -m "feat(app): per-slider lock toggle + undo-on-edit hook"
Task 8: Lock toggles on the non-scalar controls¶
Files:
- Modify: apple/PhysarumApp/Sources/Editor/Sections.swift
Give the spawn/blend pickers and the species + background color pickers a lock toggle, so the lock surface is complete (matches spec §5 "Lock-Icon pro Param").
- [ ] Step 1: Wrap the GlobalSection non-scalar controls with lock toggles
In Sections.swift GlobalSection.body, replace the two Pickers + RGBColorPicker (lines 54–66) with:
HStack {
Picker("Spawn", selection: Binding(
get: { state.edited.spawnPattern },
set: { state.edited.spawnPattern = $0 })) {
ForEach(SpawnPattern.allCases, id: \.self) { Text($0.rawValue.capitalized).tag($0) }
}
LockButton(state: state, id: .spawnPattern)
}
HStack {
Picker("Blend", selection: Binding(
get: { state.edited.blendMode },
set: { state.edited.blendMode = $0 })) {
ForEach(BlendMode.allCases, id: \.self) { Text($0.rawValue.capitalized).tag($0) }
}
LockButton(state: state, id: .blendMode)
}
HStack {
RGBColorPicker(title: "Background",
rgb: Binding(get: { state.edited.backgroundColor },
set: { state.edited.backgroundColor = $0 }))
LockButton(state: state, id: .backgroundColor)
}
- [ ] Step 2: Add a lock toggle to the species color picker
In SpeciesSection.body, replace the RGBColorPicker(title: "Color", …) block (lines 81–86) with:
HStack {
RGBColorPicker(title: "Color",
rgb: Binding(get: { state.edited.species[focus].color },
set: { newC in
if tab == 3 { for i in 0..<3 { state.edited.species[i].color = newC } }
else { state.edited.species[focus].color = newC }
}))
LockButton(state: state, id: .speciesColor(focus))
}
- [ ] Step 3: Build + full suite
Run: cd apple/PhysarumApp && xcodegen generate && xcodebuild -scheme Physarum -destination 'platform=macOS' build 2>&1 | tail -20 && xcodebuild test -scheme Physarum -destination 'platform=macOS' 2>&1 | tail -20
Expected: BUILD SUCCEEDED + all tests PASS.
- [ ] Step 4: Commit
cd "$(git rev-parse --show-toplevel)"
git add apple/PhysarumApp/Sources/Editor/Sections.swift
git commit -m "feat(app): lock toggles on non-scalar editor controls"
Task 9: Exploration toolbar (Randomize · Mutate · Undo · Redo) + hotkeys¶
Files:
- Modify: apple/PhysarumApp/Sources/Editor/EditorView.swift
- [ ] Step 1: Add a toolbar row + the @State for the morph sheet
In EditorView.swift, add below @State private var showSave = false (line 7):
In body, insert a toolbar view between header and the first PhysSection (after line 12 header):
Add the toolbar computed view after the header property (after line 61):
private var toolbar: some View {
HStack(spacing: Theme.Space.s2) {
toolButton("die.face.5", "Randomize") { state.randomize() }
toolButton("dial.medium", "Mutate") { state.mutate() }
toolButton("arrow.left.and.right", "Morph…") { showMorph = true }
Spacer()
toolButton("arrow.uturn.backward", "Undo") { state.undoEdit() }
.disabled(!state.canUndo)
.keyboardShortcut("z", modifiers: .command)
toolButton("arrow.uturn.forward", "Redo") { state.redoEdit() }
.disabled(!state.canRedo)
.keyboardShortcut("z", modifiers: [.command, .shift])
}
}
private func toolButton(_ icon: String, _ help: String, _ action: @escaping () -> Void) -> some View {
Button(action: action) {
Image(systemName: icon)
.font(.system(size: 13))
.foregroundStyle(Theme.Colors.fgSecondary)
.frame(width: 28, height: 24)
}
.buttonStyle(.plain)
.help(help)
}
- [ ] Step 2: Attach the morph sheet
In body, add after the existing .sheet(isPresented: $showSave) { … } (line 33):
The
MorphSheettype is created in Task 10. Until then this won't compile — implement Task 10 before building. (If executing strictly task-by-task, defer Step 3's build to after Task 10.)
- [ ] Step 3: (after Task 10) Build + full suite
Run: cd apple/PhysarumApp && xcodegen generate && xcodebuild -scheme Physarum -destination 'platform=macOS' build 2>&1 | tail -20
Expected: BUILD SUCCEEDED.
- [ ] Step 4: Commit (combined with Task 10) — see Task 10 Step 5.
Task 10: MorphSheet UI¶
Files:
- Create: apple/PhysarumApp/Sources/Editor/MorphSheet.swift
Morph A = the current edited snapshot captured when the sheet opens; B = a preset chosen from the catalog. The slider drives state.edited = morphParams(A, B, t) live (the player shows it immediately; agentCount changes ride the existing 250 ms respawn debounce). "Apply" records one undo entry (A) and keeps the result; "Cancel" restores A.
- [ ] Step 1: Implement MorphSheet
Create apple/PhysarumApp/Sources/Editor/MorphSheet.swift:
import SwiftUI
import PhysarumEngine
/// Morph between the current edit (A) and a chosen preset (B). The main player
/// previews the blend live as the slider moves.
struct MorphSheet: View {
@Bindable var state: AppState
@Binding var isPresented: Bool
@State private var baseA: PresetParams?
@State private var targetID: String = ""
@State private var t: Double = 0
private var targetPreset: Preset? { state.presets.first { $0.id == targetID } }
var body: some View {
VStack(alignment: .leading, spacing: Theme.Space.s3) {
Text("MORPH").font(Theme.Fonts.eyebrow).tracking(2)
.foregroundStyle(Theme.Colors.fgSecondary)
Picker("Morph to", selection: $targetID) {
ForEach(state.presets, id: \.id) { Text($0.name).tag($0.id) }
}
.onChange(of: targetID) { _, _ in applyMorph() }
HStack {
Text("A").font(Theme.Fonts.value).foregroundStyle(Theme.Colors.fgTertiary)
Slider(value: $t, in: 0...1) { editing in if !editing { applyMorph() } }
.tint(Theme.Colors.accent)
.onChange(of: t) { _, _ in applyMorph() }
Text("B").font(Theme.Fonts.value).foregroundStyle(Theme.Colors.fgTertiary)
}
HStack {
PhysButton(title: "Cancel", kind: .ghost) {
if let a = baseA { state.edited = a }
isPresented = false
}
PhysButton(title: "Apply", kind: .ember) {
if let a = baseA { state.pushUndo(a) } // undo returns to A
isPresented = false
}
}
}
.padding(Theme.Space.s4)
.frame(minWidth: 320)
.background(Theme.Colors.surface)
.foregroundStyle(Theme.Colors.fgPrimary)
.onAppear {
baseA = state.edited
if targetID.isEmpty {
targetID = state.presets.first { $0.id != state.selectedID }?.id
?? state.presets.first?.id ?? ""
}
}
}
private func applyMorph() {
guard let a = baseA, let b = targetPreset else { return }
state.edited = morphParams(a, b.params, t: t)
}
}
- [ ] Step 2: Build to verify Task 9 + 10 compile together
Run: cd apple/PhysarumApp && xcodegen generate && xcodebuild -scheme Physarum -destination 'platform=macOS' build 2>&1 | tail -20
Expected: BUILD SUCCEEDED.
- [ ] Step 3: Run the full test suite (no regression)
Run: xcodebuild test -scheme Physarum -destination 'platform=macOS' 2>&1 | tail -25
Expected: all tests PASS (UndoRedo 5, Locks 3, Randomize 6, Mutate 4, Morph 5, plus existing ParamRegistry 3, PresetStore 6).
- [ ] Step 4: Manual verification (macOS, by Johannes or via screenshot)
Launch the app and confirm: - Editor toolbar shows Randomize / Mutate / Morph / Undo / Redo; Undo/Redo disable correctly. - Randomize visibly changes the field and keeps it "alive" (not blank/blown-out); colors change. - Locking a slider (🔒) then Randomize leaves that param fixed. - ⌘Z undoes the last action; ⌘⇧Z redoes. - Morph sheet: pick a target preset, drag A↔B — the player blends live; Apply keeps the blend; Cancel restores.
Screenshot recipe (Kuro screensaver overlays on idle):
killall ScreenSaverEngine; caffeinate -u -t 12 & osascript -e 'tell application "Physarum" to activate'; sleep 1; screencapture -x /tmp/physarum-explore.png
- [ ] Step 5: Commit Tasks 9 + 10
cd "$(git rev-parse --show-toplevel)"
git add apple/PhysarumApp/Sources/Editor/EditorView.swift apple/PhysarumApp/Sources/Editor/MorphSheet.swift
git commit -m "feat(app): exploration toolbar + MorphSheet UI (randomize/mutate/morph/undo/redo)"
Task 11: Final gate + docs¶
Files:
- Modify: AGENTS.md (status line), .remember/remember.md (handoff) — optional, per session convention.
- [ ] Step 1: Full app suite + engine suite green
Run: cd apple/PhysarumApp && xcodebuild test -scheme Physarum -destination 'platform=macOS' 2>&1 | tail -25
Then: cd apple/PhysarumEngine && swift test 2>&1 | tail -10
Expected: both green.
- [ ] Step 2: Verify on a real iPhone/iPad (optional, now unblocked)
xcodebuild -scheme Physarum -destination 'generic/platform=iOS' build and deploy via Xcode (free-provisioning login already done). Confirm Randomize/Undo work via touch (tap-to-type for precise, lock toggles tappable). Note: the compact-UI Bottom-Sheet layout is a separate increment — here just confirm the editor functions on device.
- [ ] Step 3: Push
Self-Review (completed by plan author)¶
Spec coverage (§7 + §10 "In"):
- Randomize → Task 4 (lock-aware, safe sub-ranges per §11 risk → Task 3). ✓
- Mutate (±~15 %) → Task 5. ✓
- Locks (Set<ParamID> via 🔒) → Task 2 (state) + Task 7/8 (UI). ✓
- Undo/Redo (snapshot stacks + ⌘Z/⌘⇧Z) → Task 1 + Task 9. ✓
- Morph (two presets + slider, registry interpolation, discrete @0.5, agentCount→debounced respawn) → Task 6 + Task 10. ✓
- Debounced respawn (§8) → already exists in RenderCoordinator; no task needed (verified). ✓
- Tests (§9: registry roundtrip already covered; randomize-respects-locks/in-range; mutate-in-range; morph endpoints+0.5; undo/redo push/pop/redo-invalidation) → Tasks 1–6. ✓
§11 risks addressed:
- ParamID completeness → uses the existing complete ParamID enum; verified 45 scalars + non-scalars.
- Randomize sub-ranges → Task 3 bands from observed preset distributions (not raw zod).
- App-test-target → confirmed PhysarumAppTests hosted target; all new tests go there.
- Thread-safety → every feature mutates state.edited on the main thread; RenderCoordinator untouched; no new threads.
- Keying convention → locks use Set<ParamID>; respawnIDs: Set<String> left as-is (no new String/ParamID drift introduced).
Out-of-scope (correctly deferred): macOS Shift/Alt fine/coarse slider modifiers; iPad/iPhone compact Bottom-Sheet layout; auto-animated morph; preset-Morph reproducible seed sharing; correlated-param guards for randomize (safe bands already prevent most blow-outs — note as future refinement).
Type consistency: pushUndo(_:), undoEdit(), redoEdit(), canUndo, canRedo, isLocked(_:), toggleLock(_:), randomize(using:)/randomize(), mutate(strength:using:)/mutate(strength:), morphParams(_:_:t:), RandomizeProfiles.strategy(for:), RandomStrategy.sample(using:)/.span, hsv2rgb/mixRGB, LockButton(state:id:), MorphSheet(state:isPresented:) — names consistent across all tasks and call sites.