Zum Inhalt

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 a Hashable enum covering every editable param incl. species-indexed cases (moveSpeed(Int) …), matrix(Int,Int), and the non-scalars spawnPattern/blendMode/backgroundColor/speciesColor(Int). It is directly usable as a lock-set key.
  • ParamRegistry.all = 45 scalar ParamDescriptors (id,label,section,range: ClosedRange<Double>,isInt,isRespawn,get,set,idKey). Non-scalars deliberately have no descriptor.
  • AppState has edited/baseline: PresetParams, computed isDirty via the free function paramsEqual(_:_:), reset(_:), resetAll(), and full custom-preset CRUD. No locks/undo/redo/randomize/mutate/morph exist yet.
  • ParamSlider already does tap-to-type + double-tap reset; it uses a continuous Slider(value:in:) with no onEditingChanged hook and no lock affordance.
  • RenderCoordinator.setParams(_:) already live-updates the sim every frame and fires a 250 ms debounced respawn when agentCount/spawnPattern/any populationShare changes. Do not modify it.
  • Tests live in the hosted macOS XCTest target PhysarumAppTests (Tests/), pattern import XCTest / @testable import Physarum / import PhysarumEngine, seed via PresetCatalog.fallbackPreset.params. New .swift files under Tests/ are auto-included after xcodegen 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 use accuracy: 0.5 (as ParamRegistryTests does). PresetParams is not Equatable — compare via paramsEqual(_:_:) or per-descriptor get.

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):

    var locked: Set<ParamID> = []
    var undoStack: [PresetParams] = []
    var redoStack: [PresetParams] = []

In select(id:) (after the baseline = p.params line, inside the function), clear history so undo never crosses a preset boundary:

        undoStack.removeAll()
        redoStack.removeAll()
  • [ ] 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 to case .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:

        .onTapGesture(count: 2) { state.pushUndo(); state.reset(descriptor) }

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):

    @State private var showMorph = false

In body, insert a toolbar view between header and the first PhysSection (after line 12 header):

                toolbar

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):

        .sheet(isPresented: $showMorph) { MorphSheet(state: state, isPresented: $showMorph) }

The MorphSheet type 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
cd "$(git rev-parse --show-toplevel)"
git push   # main → Codeberg (+ GitHub mirror)

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.