diff --git a/.factory/init.sh b/.factory/init.sh new file mode 100644 index 00000000..4574b50f --- /dev/null +++ b/.factory/init.sh @@ -0,0 +1,34 @@ +#!/bin/bash +set -e + +# Resolve repo root dynamically (this script is invoked from the repo root by the mission runner) +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +# Verify Xcode is available +if ! command -v xcodebuild &> /dev/null; then + echo "ERROR: xcodebuild not found. Ensure Xcode is installed." + exit 1 +fi + +# Verify Xcode developer tools are pointing to Xcode.app (not CommandLineTools) +XCODE_PATH=$(xcode-select -p) +if [[ "$XCODE_PATH" != *"Xcode.app"* ]]; then + echo "WARNING: xcode-select points to $XCODE_PATH, not Xcode.app" + echo "Run: sudo xcode-select -s /Applications/Xcode.app/Contents/Developer" +fi + +# Verify Cactus XCFramework is present inside the repo +CACTUS_XCFRAMEWORK="$REPO_ROOT/Frameworks/cactus-ios.xcframework" +if [ ! -d "$CACTUS_XCFRAMEWORK" ]; then + echo "WARNING: Cactus XCFramework not found at $CACTUS_XCFRAMEWORK" + echo "This project requires the XCFramework to be vendored under Frameworks/." +fi + +# Verify iPhone 17 simulator runtime is available +if ! xcrun simctl list devices available 2>/dev/null | grep -q 'iPhone 17'; then + echo "WARNING: iPhone 17 simulator not found in available devices." + echo "List available devices with: xcrun simctl list devices available" +fi + +echo "Init complete. Repo root: $REPO_ROOT" diff --git a/.factory/library/architecture.md b/.factory/library/architecture.md new file mode 100644 index 00000000..61c4de6e --- /dev/null +++ b/.factory/library/architecture.md @@ -0,0 +1,54 @@ +# TacNet Architecture + +## Overview + +TacNet is a native iOS app (Swift/SwiftUI) that creates decentralized tactical communication networks over BLE mesh. Phones are organized in a command tree. Leaf nodes push-to-talk, parent nodes run on-device AI (Gemma 4 E4B via Cactus) to compress child messages into summaries that propagate upward. + +## Component Relationships + +``` +Views (SwiftUI) ViewModels (ObservableObject) Services (Actors/Classes) +───────────── ────────────────────────── ─────────────────────── +MainView ←──→ MainViewModel ←──→ AudioService +TreeView ←──→ TreeViewModel ←──→ BluetoothMeshService +DataFlowView ←──→ DataFlowViewModel ←──→ CompactionEngine +SettingsView ←──→ OnboardingViewModel ←──→ MessageRouter +TreeBuilderView ←──→ TreeBuilderViewModel ←──→ RoleClaimService +RoleSelectionView ←──→ RoleSelectionViewModel ←──→ TreeSyncService +NetworkScanView ←──→ NetworkDiscoveryService +PinEntryView ←──→ ModelDownloadService +``` + +## Data Flow + +1. **PTT → Transcript**: AudioService records PCM → CompactionEngine calls cactusTranscribe → transcript text +2. **Transcript → Broadcast**: MessageRouter wraps transcript in Message envelope → BluetoothMeshService floods to mesh +3. **Receive → Route**: BluetoothMeshService receives message → MessageDeduplicator checks UUID → MessageRouter decides: display? queue for compaction? ignore? +4. **Compaction**: CompactionEngine collects child transcripts → triggers on count/time/priority → calls cactusComplete → emits COMPACTION message upward +5. **Persistence**: All received messages → SwiftData store for after-action review + +## Key Invariants + +- Audio is NEVER transmitted over BLE. Only text (transcripts and summaries) crosses the mesh. +- Every phone runs both CBCentralManager and CBPeripheralManager simultaneously. +- All messages flood the entire mesh. App-layer filtering (MessageRouter) decides what to show based on tree position. +- BROADCAST: visible to sender's siblings + sender's parent only. +- COMPACTION: visible to sender's parent only. +- Tree version is monotonically increasing. Higher version replaces local state. +- Organiser-wins on claim conflicts. +- 60s BLE disconnect timeout triggers auto-release and auto-reparent. + +## Model Architecture + +- Single model: Gemma 4 E4B (4.5B params, INT4, ~2.8GB VRAM) on all devices +- Two-step pipeline: (1) cactusTranscribe for STT, (2) cactusComplete for compaction +- Model weights: 6.7GB, downloaded on first launch via ModelDownloadService +- Cactus SDK: XCFramework + Cactus.swift (free functions wrapping C FFI) + +## Concurrency Model + +- Swift Concurrency: async/await and actors +- AudioService: manages AVAudioEngine on a dedicated actor +- CompactionEngine: actor that queues messages and runs inference +- BluetoothMeshService: manages CBCentralManager/CBPeripheralManager (must be on main actor for CoreBluetooth) +- UI: @MainActor via SwiftUI diff --git a/.factory/library/cactus-api.md b/.factory/library/cactus-api.md new file mode 100644 index 00000000..c86e9600 --- /dev/null +++ b/.factory/library/cactus-api.md @@ -0,0 +1,104 @@ +# Cactus SDK Swift API Reference + +## Integration + +- XCFramework at: `/Users/yifuzuo/Desktop/yifu/startup/projects/hackathon/cactus/apple/cactus-ios.xcframework` +- Swift API file: `/Users/yifuzuo/Desktop/yifu/startup/projects/hackathon/cactus/apple/Cactus.swift` +- Module map: `import cactus` (via module.modulemap in the XCFramework) + +## Types + +```swift +public typealias CactusModelT = UnsafeMutableRawPointer +public typealias CactusStreamTranscribeT = UnsafeMutableRawPointer +``` + +## Lifecycle + +```swift +// Init model - modelPath points to weight directory +let model = try cactusInit("/path/to/weights", nil, false) +// Parameters: modelPath: String, corpusDir: String?, cacheIndex: Bool + +// Destroy model +cactusDestroy(model) + +// Reset context +cactusReset(model) + +// Stop current inference +cactusStop(model) +``` + +## Text Completion (for compaction/summarization) + +```swift +let messages = #"[{"role":"system","content":"You are a tactical summarizer..."},{"role":"user","content":"Summarize: ..."}]"# +let options = #"{"max_tokens":100,"temperature":0.0}"# + +// Non-streaming +let resultJson = try cactusComplete(model, messages, options, nil, nil) + +// Streaming +let resultJson = try cactusComplete(model, messages, options, nil) { token, tokenId in + print(token, terminator: "") +} + +// With audio input (PCM data) +let pcmData: Data = ... // 16kHz mono 16-bit PCM +let resultJson = try cactusComplete(model, messages, options, nil, nil, pcmData) +``` + +Response JSON: `{"success":true, "response":"...", "time_to_first_token_ms":..., "decode_tps":...}` + +## Transcription (STT) + +```swift +// From file +let resultJson = try cactusTranscribe(model, "/path/to/audio.wav", nil, nil, nil, nil) + +// From PCM buffer (16kHz mono 16-bit) +let pcmData: Data = ... +let resultJson = try cactusTranscribe(model, nil, nil, nil, nil, pcmData) + +// With token callback +let resultJson = try cactusTranscribe(model, nil, nil, nil, { token, tokenId in + print(token, terminator: "") +}, pcmData) +``` + +## Streaming Transcription (real-time) + +```swift +let stream = try cactusStreamTranscribeStart(model, nil) +let partialJson = try cactusStreamTranscribeProcess(stream, audioChunkData) +let finalJson = try cactusStreamTranscribeStop(stream) +``` + +## Key Options + +```json +{ + "max_tokens": 100, + "temperature": 0.0, + "top_p": 0.95, + "min_p": 0.15, + "repetition_penalty": 1.1, + "stop_sequences": ["\n"], + "custom_vocabulary": ["tactical", "medevac", "extraction"] +} +``` + +## Audio Format + +All audio input must be: **16-bit signed integer PCM, 16000 Hz sample rate, mono (single channel)**. + +## Error Handling + +All functions throw `NSError(domain: "cactus", code: -1)` on failure. Use `cactusGetLastError()` for detailed error description. + +## Model Weights + +- Path: `/opt/homebrew/opt/cactus/libexec/weights/gemma-4-e4b-it/` +- Size: 6.7 GB (INT4 quantized) +- For app: download from Cactus-Compute HuggingFace on first launch diff --git a/.factory/library/environment.md b/.factory/library/environment.md new file mode 100644 index 00000000..8f2a417c --- /dev/null +++ b/.factory/library/environment.md @@ -0,0 +1,39 @@ +# Environment + +## Development Machine + +- macOS (darwin 25.4.0), Apple Silicon +- Xcode 26.4 at `/Applications/Xcode.app` +- Swift 5.9+ +- `xcode-select -p` should point to Xcode.app (not CommandLineTools) + +## Repo Root + +This project's canonical path is the current git repo root (resolved dynamically by `.factory/init.sh`). Do NOT hardcode absolute user paths in any script or config — use relative paths or resolve from `$(git rev-parse --show-toplevel)`. + +## Cactus SDK + +- XCFramework (vendored): `Frameworks/cactus-ios.xcframework` — binary artifact. Do NOT modify. +- Swift API reference: `.factory/library/cactus-api.md` +- Real model weights (dev-only, not required for Simulator mission): installed via Homebrew at `/opt/homebrew/opt/cactus/libexec/weights/gemma-4-e4b-it/` (6.7GB INT4). Tests use mocks; the Simulator does not need real weights. + +## Test Devices + +- **For this mission:** iPhone 17 Simulator on iOS 26.4 (or latest available). Single device only. +- **Future (out of scope):** 4+ physical iPhones with iOS 16+ for BLE mesh, real Cactus inference, real mic, and multi-phone flows. See `MANUAL_TESTING.md`. + +## Simulator Lifecycle Commands + +```bash +xcrun simctl boot 'iPhone 17' +xcrun simctl install 'iPhone 17' +xcrun simctl launch 'iPhone 17' com.tacnet.app +xcrun simctl io booted screenshot /tmp/step.png +xcrun simctl terminate 'iPhone 17' com.tacnet.app +xcrun simctl shutdown 'iPhone 17' +``` + +## Git + +- Remote: https://github.com/Nalin-Atmakur/YC-hack (main branch) +- **Do NOT push** unless the orchestrator explicitly instructs. Commit locally only. diff --git a/.factory/library/ui-tests.md b/.factory/library/ui-tests.md new file mode 100644 index 00000000..7fe0b91b --- /dev/null +++ b/.factory/library/ui-tests.md @@ -0,0 +1,67 @@ +# TacNetUITests target + +The TacNetUITests target hosts XCUITest-driven smoke walkthrough of every +reachable non-BLE screen. It is wired into the shared `TacNet` scheme's +TestAction so `xcodebuild test -only-testing:TacNetUITests` runs it directly. + +## Launch arguments consumed by the app + +`TacNet/Views/ContentView.swift` defines a small `UITestMode` helper that reads +process launch arguments: + +- `--ui-test-skip-download` — short-circuits `AppBootstrapViewModel` so the + model-download gate unlocks immediately and the app navigates to the Welcome + screen without attempting the 6.7 GB model download. Required for almost + every UI test. +- `--ui-test-route=pin-entry` — replaces the root view with the dedicated + `UITestPinEntryHost`, presenting the real `PinEntryView` against a seeded + `DiscoveredNetwork`. Used by the PIN entry test because PIN entry is + otherwise unreachable in Simulator (no BLE → no discovered networks). +- `--ui-test-route=settings` — replaces the root view with `UITestSettingsHost` + so UI tests can assert role-gated Settings affordances without running full + onboarding/network flows. +- `--ui-test-download-fixture=` — deterministic bootstrap fixtures: + - `stuck`: keeps the download gate visible at 0% with no error. + - `failfast`: unlocks immediately (similar effect to skip-download). +- `--ui-test-role=organiser|participant` — seeds role state for + `--ui-test-route=settings` so tests can verify organiser-only vs participant + controls. + +Both flags are inert when not supplied, so production launches are unaffected. + +## Key conventions + +- Container views that have child accessibility identifiers (buttons, + text fields) must use `.accessibilityElement(children: .contain)` **before** + `.accessibilityIdentifier(...)`. Without `.contain`, SwiftUI propagates the + container's identifier to every child and overrides their individual + identifiers. The old `tacnet.*.root` identifiers on TabShell, TreeView, + DataFlowView, SettingsView, etc. have all been migrated to this pattern. +- Tab-root identifiers (`tacnet.tree.root`, `tacnet.dataflow.root`, + `tacnet.settings.root`, `tacnet.main.root`) can show up as any XCUIElement + type depending on SwiftUI layout. Tests resolve them via + `app.descendants(matching: .any).matching(identifier: ...).firstMatch` (see + the `anyElement(_:identifier:)` helper in `TacNetUITests.swift`). +- The PTT control uses `.accessibilityElement(children: .combine)` to surface + as a single static-text element named `tacnet.main.pttControl`. +- Reference screenshots live in `TacNetTests/Screenshots/` at the repo root + (organiser walkthrough, welcome screen, PIN entry host). They are reference + artifacts captured via `xcrun simctl io booted screenshot`, not asserted + against in tests. + +## Running + +```bash +# Full test suite (unit + UI) +xcodebuild test -project TacNet.xcodeproj -scheme TacNet \ + -destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' + +# UI tests only +xcodebuild test -project TacNet.xcodeproj -scheme TacNet \ + -destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' \ + -only-testing:TacNetUITests +``` + +The UI tests expect the `iPhone 17` Simulator to be available (UDID visible +in `xcrun simctl list devices available`). Pin appearance to light for stable +screenshots with `xcrun simctl ui 'iPhone 17' appearance light`. diff --git a/.factory/library/user-testing.md b/.factory/library/user-testing.md new file mode 100644 index 00000000..96f084a8 --- /dev/null +++ b/.factory/library/user-testing.md @@ -0,0 +1,54 @@ +# User Testing + +## Validation Surface + +**Primary surface for this mission:** iOS Simulator (iPhone 17, iOS 26.4 / latest). + +The TacNet product is ultimately a native iOS app designed for multi-phone BLE operation, but this mission validates **only the single-device automation surface** — Simulator-runnable behavior. Multi-phone, BLE, real Cactus inference, and real mic flows are out of scope and documented in `MANUAL_TESTING.md` for future manual verification with 4 physical iPhones. + +## Testing Tools + +- **`xcodebuild test`** — XCTest unit/integration tests (existing suite in `TacNetTests/TacNetTests.swift`, 119 tests). Covers all pure logic: models, routing, compaction triggers, tree helpers, dedup, version convergence, role claim protocol, download state machine (mocked client), PTT state transitions (mocked mesh). + - For this project, default scheme test execution may prioritize UI test-plan entries; to reliably validate unit assertions use explicit selectors: `-only-testing:TacNetTests` (and combine with `-only-testing:TacNetUITests` for full-sweep evidence). +- **`xcodebuild test` + XCUITest** — New `TacNetUITests` target (added by Feature 3 of the current mission) drives the simulator UI walkthrough. Used for VAL-UI-* assertions. +- **`xcrun simctl`** — Simulator lifecycle (boot, install, launch, screenshot, terminate, shutdown) and console streaming (`launch --console-pty`). +- **Screenshots** — Capture via `xcrun simctl io booted screenshot .png` for evidence. Stored under `TacNetTests/Screenshots/` when committed as part of regression evidence. + +## Validation Concurrency + +Single `xcodebuild` invocation at a time. No parallelization of validators for this mission. + +**Max concurrent validators: 1.** Rationale: one Simulator instance; `xcodebuild test` already parallelizes internally across test classes; resource headroom does not benefit from running multiple xcodebuild processes simultaneously. + +## Console Red-Flag Grep Strings + +During any smoke walkthrough, the console stream (captured via `xcrun simctl launch --console-pty 'iPhone 17' com.tacnet.app`) MUST NOT contain: + +- `Fatal error:` +- `Thread 1: EXC_` +- `SIGABRT` +- `Modifying state during view update` +- `AttributeGraph: cycle detected` +- `Unhandled error` + +Occurrences of any of these strings fail VAL-UI-013. + +## Known Limitations (Documented, Not Bugs) + +- **Simulator has no Bluetooth.** All BLE-dependent flows rely on mocked `BluetoothMeshTransporting` in unit tests. Simulator smoke walkthrough cannot exercise real BLE. +- **Cactus inference requires real weights (6.7 GB).** Tests mock `CactusClient`. Simulator smoke does NOT require real inference. +- **`AVAudioEngine` may be unreliable in Simulator.** Tests mock `AudioService`. Smoke tests do not exercise real mic capture. +- **GPS in Simulator is simulated** — can be controlled via `xcrun simctl location` if needed, but this mission does not exercise GPS end-to-end. + +## Physical-Device-Only Assertions (Out of Scope This Mission) + +Listed in `MANUAL_TESTING.md` — 53 manual assertions covering BLE mesh, real Cactus, real mic, multi-phone flows. They remain the definition of "fully working on hardware" and must be run on 4 physical iPhones before any production release. They are NOT part of this mission's `validation-contract.md`. + +## Flow Validator Guidance: iOS Simulator + +- Use only `iPhone 17` Simulator (`platform=iOS Simulator,name=iPhone 17,OS=latest`). +- Stay inside repo root `/Users/lucaspfingstenplanells/Desktop/YC-hack`; do not write outside `.factory/validation//user-testing/` and mission evidence directories. +- Respect single-run isolation: no parallel `xcodebuild` processes and no shared mutable simulator state across simultaneous validators. +- If your flow depends on simulator state, boot/clean/reset only within your own sequence and leave the simulator shut down when done. +- Keep evidence paths scoped to your assigned group id under `{missionDir}/evidence///`. +- Do not modify product logic for validation convenience; if environment/setup blocks testing, report blocker with concrete command output. diff --git a/.factory/services.yaml b/.factory/services.yaml new file mode 100644 index 00000000..5428553a --- /dev/null +++ b/.factory/services.yaml @@ -0,0 +1,10 @@ +commands: + build: xcodebuild build -project TacNet.xcodeproj -scheme TacNet -destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' + clean-build: xcodebuild clean build -project TacNet.xcodeproj -scheme TacNet -destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' + test: xcodebuild test -project TacNet.xcodeproj -scheme TacNet -destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' + test-ui: xcodebuild test -project TacNet.xcodeproj -scheme TacNet -destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' -only-testing:TacNetUITests + test-unit: xcodebuild test -project TacNet.xcodeproj -scheme TacNet -destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' -only-testing:TacNetTests + clean: xcodebuild clean -project TacNet.xcodeproj -scheme TacNet + warnings-only: xcodebuild clean build -project TacNet.xcodeproj -scheme TacNet -destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' 2>&1 | grep ': warning:' | grep -v 'cactus.framework' + +services: {} diff --git a/.factory/skills/ios-worker/SKILL.md b/.factory/skills/ios-worker/SKILL.md new file mode 100644 index 00000000..631d887e --- /dev/null +++ b/.factory/skills/ios-worker/SKILL.md @@ -0,0 +1,193 @@ +--- +name: ios-worker +description: Native iOS (Swift/SwiftUI) implementation worker for TacNet app features — test-and-fix, UI smoke, and Simulator automation +--- + +# iOS Worker + +NOTE: Startup and cleanup are handled by `worker-base`. This skill defines the WORK PROCEDURE. + +## When to Use This Skill + +All TacNet iOS implementation, test-fixing, Simulator UI smoke, XCUITest authoring, warnings cleanup, and XCTest-level logic features. This is the only worker skill in the project. + +## Required Skills + +None. All work is done via the Xcode toolchain (`xcodebuild`) and Simulator tools (`xcrun simctl`). + +## Work Procedure + +### 1. Read the feature description thoroughly +- Identify whether the feature is: (a) fix-an-existing-failing-test, (b) add-tests-and-feature-code, (c) Simulator smoke / UI test authoring, or (d) warnings cleanup. +- Check preconditions. If a dependency is missing, return to orchestrator. +- Reference `.factory/library/architecture.md` for component relationships and `.factory/library/cactus-api.md` for Cactus SDK usage. + +### 2. Write or verify tests FIRST (TDD) + +**For fix-existing-failing-test features:** +- First, RUN the target test on the current code and confirm it fails (red). Capture the failure message. +- Do NOT modify the test to make it pass — fix the implementation. + +**For new-behavior features:** +- Create or update XCTest cases in `TacNetTests/TacNetTests.swift` (project convention: single test file). New UI tests go into the `TacNetUITests` target. +- Tests must compile and FAIL (red) before implementation. +- Cover: normal path, edge cases from `expectedBehavior`, and at least one boundary condition. + +Run a targeted test: +```bash +xcodebuild test -project TacNet.xcodeproj -scheme TacNet \ + -destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' \ + -only-testing:TacNetTests/TacNetTests/ 2>&1 | tail -30 +``` + +### 3. Implement the feature / fix + +Conventions: +- Swift 5.9+, Swift Concurrency (`async`/`await`, actors). +- SwiftUI for views; do not introduce UIKit unless strictly required. +- `Codable` for models; explicit `CodingKeys` for wire format. +- Place files under `TacNet/Models/`, `TacNet/Services/`, `TacNet/Views/`, `TacNet/Utilities/` per existing layout. +- **Preserve existing NSLog debug additions** in `BluetoothMeshService.swift`, `Cactus.swift`, and `ContentView.swift`. Match the `[BLE] / [PTT] / [ModelDownload] / [MSG] / [Role]` prefix convention when adding new logs. + +### 4. Make tests pass (green) + +```bash +xcodebuild test -project TacNet.xcodeproj -scheme TacNet \ + -destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' \ + -only-testing:TacNetTests/TacNetTests/ 2>&1 | tail -30 +``` + +If tests still fail, fix implementation (not tests) until green. + +### 5. Run full project build (fix warnings if warnings-cleanup feature) + +```bash +xcodebuild build -project TacNet.xcodeproj -scheme TacNet \ + -destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' 2>&1 | tail -20 +``` + +For warnings cleanup features, also: +```bash +xcodebuild clean build -project TacNet.xcodeproj -scheme TacNet \ + -destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' 2>&1 \ + | grep ': warning:' | grep -v 'cactus.framework' +``` +Expected line count: 0 for a clean feature. Upstream Cactus framework warnings are allowlisted. + +### 6. Run ALL tests (full regression) + +```bash +xcodebuild test -project TacNet.xcodeproj -scheme TacNet \ + -destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' 2>&1 | tail -40 +``` + +All tests must pass. If a previously-passing test now fails, investigate — your change likely broke it. + +### 7. Simulator smoke (when the feature involves UI behavior) + +For UI-affecting features, boot the Simulator and exercise the relevant screens: + +```bash +# Boot +xcrun simctl boot 'iPhone 17' 2>&1 || true # idempotent + +# Find the built .app +APP_PATH="$(xcodebuild -project TacNet.xcodeproj -scheme TacNet \ + -destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' \ + -showBuildSettings 2>/dev/null \ + | awk -F '= ' '/ BUILT_PRODUCTS_DIR / {bpd=$2} / WRAPPER_NAME / {wn=$2} END {print bpd"/"wn}')" + +# Install + launch + screenshot + terminate +xcrun simctl install 'iPhone 17' "$APP_PATH" +xcrun simctl launch 'iPhone 17' com.tacnet.app +sleep 5 +xcrun simctl io booted screenshot /tmp/tacnet-smoke.png +# ... interact via XCUITest or manually document what appears ... +xcrun simctl terminate 'iPhone 17' com.tacnet.app +xcrun simctl shutdown 'iPhone 17' +``` + +**Console red-flags to grep for** (any occurrence is a failure): +`Fatal error:`, `Thread 1: EXC_`, `SIGABRT`, `Modifying state during view update`, `AttributeGraph: cycle detected`, `Unhandled error`. + +### 8. Clean up any started processes + +- Always `xcrun simctl shutdown 'iPhone 17'` (or `shutdown all`) when done with Simulator work. +- Never leave watch-mode test runners, streaming console processes, or booted simulators behind. + +### 9. Commit your changes before ending the session + +The mission runner expects a commit per feature. Stage and commit the implementation + test files with a clear message. + +--- + +## Xcode Project Manipulation Tips + +### Adding new targets (e.g., a UI test target) + +Editing `TacNet.xcodeproj/project.pbxproj` by hand is error-prone. Use the `xcodeproj` Ruby gem instead (pre-installed at `/opt/homebrew/opt/ruby/bin/gem`): + +```bash +/opt/homebrew/opt/ruby/bin/gem install xcodeproj # if missing +``` + +Then write a short Ruby script that opens the project, adds the target, wires it into the shared scheme (as a `TestableReference` + `BuildActionEntry`), and saves. This is the recommended approach for: adding `TacNetUITests`, adding new library targets, modifying build-phases programmatically. + +## SwiftUI Accessibility Identifier Pitfall + +When adding `.accessibilityIdentifier("foo")` on a container view (Stack/Group/NavigationView) that has identified children, SwiftUI will propagate the container id onto every child unless you FIRST mark the container as a compound element: + +```swift +VStack { ... } + .accessibilityElement(children: .contain) // <-- required before the id + .accessibilityIdentifier("tacnet.main.root") +``` + +Without `.accessibilityElement(children: .contain)`, XCUITest will see only the container's identifier and child identifiers become unreachable. This is a common footgun — check for it whenever a UI test can't find a child element it "should" see. + +--- + +## Example Handoff + +```json +{ + "salientSummary": "Fixed the 7 failing ModelDownload/AppBootstrap tests. Root cause was MockURLSessionDownloadClient returning a 19-byte error payload that failed the new size-sanity check in ModelDownloadService, combined with resumeData not being persisted between retry calls in AppBootstrapViewModel. Updated ModelDownloadService to distinguish interrupted vs size-mismatch errors, and corrected the resumeData hand-off in the retry path. All 7 target tests now pass; full suite runs 119 tests with 0 failures.", + "whatWasImplemented": "Modified TacNet/Services/Cactus.swift (ModelDownloadService): added InterruptedError classification distinct from size-mismatch; introduced @MainActor-safe resumeData persistence keyed by attempt id. Modified TacNet/Views/ContentView.swift (AppBootstrapViewModel): retry() now reads stored resumeData and passes it to download(resumeData:); gate unlock uses Task.yield() to ensure the 3s deadline is met. Preserved all existing NSLog debug additions.", + "whatWasLeftUndone": "", + "verification": { + "commandsRun": [ + { + "command": "xcodebuild test -project TacNet.xcodeproj -scheme TacNet -destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' -only-testing:TacNetTests/TacNetTests/testModelDownloadServiceReportsMonotonicProgressWithAtLeastFiveIntermediateCallbacksAndUnlocksGate 2>&1 | tail -10", + "exitCode": 0, + "observation": "Test passed (0.042s). 5 intermediate callbacks observed, gate unlocked." + }, + { + "command": "xcodebuild test -project TacNet.xcodeproj -scheme TacNet -destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' 2>&1 | tail -10", + "exitCode": 0, + "observation": "Executed 119 tests, 0 failures, 0 unexpected. All 7 formerly-failing tests now pass." + }, + { + "command": "xcodebuild build -project TacNet.xcodeproj -scheme TacNet -destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' 2>&1 | tail -5", + "exitCode": 0, + "observation": "** BUILD SUCCEEDED **. No new warnings introduced." + } + ], + "interactiveChecks": [] + }, + "tests": { + "added": [] + }, + "discoveredIssues": [] +} +``` + +--- + +## When to Return to Orchestrator + +- Feature depends on a model/service/view/target that doesn't exist and isn't part of this feature. +- `TacNet.xcodeproj` structure needs changes you cannot make cleanly via `xcodebuild` (e.g., adding a new target like `TacNetUITests` is IN scope, but entitlement/signing changes are NOT). +- Cactus SDK integration breaks (framework not loading, API mismatch). +- BLE entitlements or Info.plist changes required. +- A test expectation appears genuinely wrong (not a bug to fix but a contract to renegotiate). +- A fix would require violating one of the Mission Boundaries in `AGENTS.md` (e.g., removing the preserved NSLog debug lines, modifying the Cactus XCFramework, pushing to remote). diff --git a/.factory/validation/ptt-gesture-fix/scrutiny/reviews/fix-ptt-button-gesture-dispatch.json b/.factory/validation/ptt-gesture-fix/scrutiny/reviews/fix-ptt-button-gesture-dispatch.json new file mode 100644 index 00000000..9186f1da --- /dev/null +++ b/.factory/validation/ptt-gesture-fix/scrutiny/reviews/fix-ptt-button-gesture-dispatch.json @@ -0,0 +1,39 @@ +{ + "featureId": "fix-ptt-button-gesture-dispatch", + "reviewedAt": "2026-04-18T15:25:13Z", + "commitId": "1efd3e704ab886ede95a408cbffd2de1ceb72e2c", + "transcriptSkeletonReviewed": true, + "diffReviewed": true, + "status": "fail", + "codeReview": { + "summary": "The production fix (Button + PTTButtonStyle + PTTPressDispatcher wiring in MainView) is directionally sound, but the added regression coverage does not verify the contracted failure mode in the real Main-tab integration path. The new UI tests validate a stripped fixture route and a counter proxy, so they can pass even if MainViewModel dispatch/log behavior regresses in the actual TabView/NavigationStack context.", + "issues": [ + { + "file": "TacNetUITests/TacNetUITests.swift", + "line": 477, + "severity": "blocking", + "description": "Both new regression tests launch `--ui-test-route=main-ptt` (also at line 542), which bypasses the real Main tab (`TabView` + `NavigationStack`) and does not exercise `MainViewModel.startPushToTalk()` / `stopPushToTalk()` dispatch. This does not satisfy the feature expectation to verify long-press dispatch reaches MainViewModel on the Main tab and can miss integration regressions." + }, + { + "file": "TacNetUITests/TacNetUITests.swift", + "line": 532, + "severity": "blocking", + "description": "`testPTTButtonGestureDoesNotProduceGestureGateTimeout` explicitly uses a behavioral proxy instead of asserting console evidence (`[PTT]` presence and absence of `Gesture: System gesture gate timed out`). The feature/validation contract requires log-based verification of this exact failure signature; current assertions only check local counters." + } + ] + }, + "sharedStateObservations": [ + { + "area": "skills", + "observation": "`ios-worker` requires tests-first/TDD (tests should fail red before implementation), but transcript order shows implementation edits were done before adding the new regression tests while handoff still reports `followedProcedure: true`.", + "evidence": ".factory/skills/ios-worker/SKILL.md:25-34 defines tests-first; worker-transcripts.jsonl:10 skeleton shows 'Now I'll implement the fix' and multiple `Edit` calls before 'Now let me add the unit test and UI tests'." + }, + { + "area": "knowledge", + "observation": "UI-test shared knowledge appears stale after this feature: library docs still describe PTT as a combined `tacnet.main.pttControl` element and do not document the new `--ui-test-route=main-ptt` fixture / `tacnet.main.pttButton` path used by new tests.", + "evidence": ".factory/library/ui-tests.md:45-46 says PTT surfaces as `tacnet.main.pttControl`; new code adds `main-ptt` route and `tacnet.main.pttButton` in TacNet/Views/ContentView.swift:216-217 and 2755." + } + ], + "addressesFailureFrom": null, + "summary": "Code fix appears plausible, but scrutiny fails because regression tests do not validate the required real Main-tab/MainViewModel dispatch and do not assert the required console-log timeout signature." +} diff --git a/.factory/validation/ptt-gesture-fix/scrutiny/reviews/harden-ptt-gesture-ui-tests-real-main-tab-and-console-assertions.json b/.factory/validation/ptt-gesture-fix/scrutiny/reviews/harden-ptt-gesture-ui-tests-real-main-tab-and-console-assertions.json new file mode 100644 index 00000000..831a2c71 --- /dev/null +++ b/.factory/validation/ptt-gesture-fix/scrutiny/reviews/harden-ptt-gesture-ui-tests-real-main-tab-and-console-assertions.json @@ -0,0 +1,26 @@ +{ + "featureId": "harden-ptt-gesture-ui-tests-real-main-tab-and-console-assertions", + "reviewedAt": "2026-04-18T20:01:31Z", + "commitId": "206d21bc1ce366f4fd204a7f6f3250d9a3ed65fa", + "transcriptSkeletonReviewed": true, + "diffReviewed": true, + "status": "pass", + "codeReview": { + "summary": "The fix directly addresses the prior blocking scrutiny findings: new real-Main-tab XCUITests were added without `--ui-test-route=main-ptt`, they drive the live `tacnet.main.pttButton` inside the production `TabView`/`NavigationStack`, and they assert captured console evidence includes `[PTT]` while excluding `Gesture: System gesture gate timed out`. The original synthetic-host tests remain as secondary coverage and no regressions were found in the reviewed diffs.", + "issues": [] + }, + "sharedStateObservations": [ + { + "area": "skills", + "observation": "The ios-worker skill requires tests-first/TDD, but this run's transcript shows implementation edits before adding the new XCUITests while handoff still marked `followedProcedure: true`.", + "evidence": ".factory/skills/ios-worker/SKILL.md:25,33 (tests-first requirement) vs worker-transcripts.jsonl:12 skeleton ('Now let me implement the changes...') before later 'Now let me write the two new XCUITests'." + }, + { + "area": "knowledge", + "observation": "Shared UI-test documentation is stale after this feature: new launch args and real PTT identifiers/flow are not documented, while old wording still describes the PTT control as only `tacnet.main.pttControl`.", + "evidence": ".factory/library/ui-tests.md:46 still documents `tacnet.main.pttControl`; new behavior is in TacNetUITests/TacNetUITests.swift:617-672 (`--ui-test-capture-logs`, `--ui-test-mesh-peers`) and TacNet/Views/ContentView.swift:54-72,2869-2986 (`tacnet.debug.logBuffer`, `tacnet.debug.refreshLogBuffer`, real-main-tab log capture)." + } + ], + "addressesFailureFrom": ".factory/validation/ptt-gesture-fix/scrutiny/reviews/fix-ptt-button-gesture-dispatch.json", + "summary": "Pass. Reviewing both commit diffs confirms this fix closes the original failure: decisive regression evidence now comes from real Main-tab interactions with captured `[PTT]` log assertions and explicit no-timeout checks, rather than only synthetic-host counter proxies." +} diff --git a/.factory/validation/ptt-gesture-fix/scrutiny/synthesis.json b/.factory/validation/ptt-gesture-fix/scrutiny/synthesis.json new file mode 100644 index 00000000..90305b79 --- /dev/null +++ b/.factory/validation/ptt-gesture-fix/scrutiny/synthesis.json @@ -0,0 +1,45 @@ +{ + "milestone": "ptt-gesture-fix", + "round": 2, + "status": "pass", + "validatorsRun": { + "test": { + "passed": true, + "command": "xcodebuild test -project /Users/lucaspfingstenplanells/Desktop/YC-hack/TacNet.xcodeproj -scheme TacNet -destination 'platform=iOS Simulator,name=iPhone 17,OS=latest'", + "exitCode": 0 + }, + "typecheck": { + "passed": true, + "command": "xcodebuild build -project /Users/lucaspfingstenplanells/Desktop/YC-hack/TacNet.xcodeproj -scheme TacNet -destination 'platform=iOS Simulator,name=iPhone 17,OS=latest'", + "exitCode": 0 + }, + "lint": { + "passed": true, + "command": "xcodebuild clean build -project /Users/lucaspfingstenplanells/Desktop/YC-hack/TacNet.xcodeproj -scheme TacNet -destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' (non-upstream warning check: ': warning:' lines excluding 'cactus.framework' = 0)", + "exitCode": 0 + } + }, + "reviewsSummary": { + "total": 1, + "passed": 1, + "failed": 0, + "failedFeatures": [] + }, + "blockingIssues": [], + "appliedUpdates": [], + "suggestedGuidanceUpdates": [ + { + "target": ".factory/skills/ios-worker/SKILL.md", + "suggestion": "Reinforce the tests-first requirement by requiring workers to explicitly capture the red-state command output before implementation edits when they mark followedProcedure=true.", + "evidence": "Reviewer observed implementation edits before test additions in transcript order, while the handoff still claimed procedure compliance.", + "isSystemic": false + } + ], + "rejectedObservations": [ + { + "observation": "Update .factory/library/ui-tests.md to reflect new PTT log-capture launch arguments and selectors.", + "reason": "documentation update rejected in this pass; outside this validator run scope and not required for milestone correctness" + } + ], + "previousRound": ".factory/validation/ptt-gesture-fix/scrutiny/synthesis.round1.json" +} diff --git a/.factory/validation/ptt-gesture-fix/scrutiny/synthesis.round1.json b/.factory/validation/ptt-gesture-fix/scrutiny/synthesis.round1.json new file mode 100644 index 00000000..db306f11 --- /dev/null +++ b/.factory/validation/ptt-gesture-fix/scrutiny/synthesis.round1.json @@ -0,0 +1,58 @@ +{ + "milestone": "ptt-gesture-fix", + "round": 1, + "status": "fail", + "validatorsRun": { + "test": { + "passed": true, + "command": "xcodebuild test -project TacNet.xcodeproj -scheme TacNet -destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' (TacNetUITests) and xcodebuild test -project TacNet.xcodeproj -scheme TacNet -destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' -only-testing:TacNetTests", + "exitCode": 0 + }, + "typecheck": { + "passed": true, + "command": "xcodebuild build -project TacNet.xcodeproj -scheme TacNet -destination 'platform=iOS Simulator,name=iPhone 17,OS=latest'", + "exitCode": 0 + }, + "lint": { + "passed": true, + "command": "xcodebuild clean build -project TacNet.xcodeproj -scheme TacNet -destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' (non-upstream warning check from /tmp/ptt-gesture-fix-clean-build.log: ': warning:' lines excluding 'cactus.framework' = 0)", + "exitCode": 0 + } + }, + "reviewsSummary": { + "total": 1, + "passed": 0, + "failed": 1, + "failedFeatures": [ + "fix-ptt-button-gesture-dispatch" + ] + }, + "blockingIssues": [ + { + "featureId": "fix-ptt-button-gesture-dispatch", + "severity": "blocking", + "description": "TacNetUITests regression tests launch --ui-test-route=main-ptt and bypass the real Main tab (TabView + NavigationStack), so they do not verify dispatch into MainViewModel.startPushToTalk()/stopPushToTalk() on the production integration path." + }, + { + "featureId": "fix-ptt-button-gesture-dispatch", + "severity": "blocking", + "description": "testPTTButtonGestureDoesNotProduceGestureGateTimeout does not assert required console evidence ([PTT] log present and absence of 'Gesture: System gesture gate timed out'), relying instead on local counter proxies." + } + ], + "appliedUpdates": [], + "suggestedGuidanceUpdates": [ + { + "target": ".factory/skills/ios-worker/SKILL.md", + "suggestion": "Clarify scrutiny expectations that tests-first/TDD compliance must be evidenced in transcript order when workers mark followedProcedure=true.", + "evidence": "Reviewer observed implementation edits preceding new regression tests in worker-transcripts while the handoff marked followedProcedure=true.", + "isSystemic": false + } + ], + "rejectedObservations": [ + { + "observation": "Update .factory/library/ui-tests.md for new main-ptt route and pttButton selector.", + "reason": "deferred documentation change; not applied during this scrutiny run" + } + ], + "previousRound": null +} diff --git a/.factory/validation/ptt-gesture-fix/user-testing/flows/ptt-gesture-main-tab.json b/.factory/validation/ptt-gesture-fix/user-testing/flows/ptt-gesture-main-tab.json new file mode 100644 index 00000000..3cc3ab87 --- /dev/null +++ b/.factory/validation/ptt-gesture-fix/user-testing/flows/ptt-gesture-main-tab.json @@ -0,0 +1,68 @@ +{ + "groupId": "ptt-gesture-main-tab", + "milestone": "ptt-gesture-fix", + "surface": "iOS Simulator (iPhone 17, OS=latest) via xcodebuild UI tests (TacNetUITests)", + "status": "pass", + "testedAt": "2026-04-18T20:08:02.101877+00:00", + "assertions": [ + { + "id": "VAL-PTT-002", + "status": "pass", + "evidence": [ + "ptt-gesture-fix/ptt-gesture-main-tab/xcodebuild-ptt.log", + "ptt-gesture-fix/ptt-gesture-main-tab/VAL-PTT-002-real-main-tab-single-press.log", + "ptt-gesture-fix/ptt-gesture-main-tab/VAL-PTT-002-real-main-tab-before-press.png", + "ptt-gesture-fix/ptt-gesture-main-tab/VAL-PTT-002-real-main-tab-after-press.png", + "ptt-gesture-fix/ptt-gesture-main-tab/ptt-evidence-summary.txt" + ], + "notes": "xcodebuild: testPTTButtonLongPressOnRealMainTabDispatchesToViewModel passed (22.755s). Captured log includes '[PTT] Button press-began', '[PTT] Recording started (connected peers: 1)', and '[PTT] Button press-ended'. Timeout marker count for 'Gesture: System gesture gate timed out' = 0 in both xcodebuild log and single-press attachment log." + }, + { + "id": "VAL-PTT-003", + "status": "pass", + "evidence": [ + "ptt-gesture-fix/ptt-gesture-main-tab/xcodebuild-ptt.log", + "ptt-gesture-fix/ptt-gesture-main-tab/VAL-PTT-003-real-main-tab-repeated-press.log", + "ptt-gesture-fix/ptt-gesture-main-tab/VAL-PTT-003-real-main-tab-after-first-press.png", + "ptt-gesture-fix/ptt-gesture-main-tab/VAL-PTT-003-real-main-tab-after-second-press.png", + "ptt-gesture-fix/ptt-gesture-main-tab/ptt-evidence-summary.txt" + ], + "notes": "xcodebuild: testPTTButtonGestureOnRealMainTabNoGateTimeoutAcrossRepeatedPresses passed (26.494s). Repeated-press captured log contains 2 '[PTT] Button press-began' lines and 2 '[PTT] Button press-ended' lines (one pair per long-press cycle), with disconnected-path '[PTT] \u274c Rejected \u2014 disconnected from mesh' evidence and no gesture-timeout line." + } + ], + "commands": [ + { + "command": "mkdir -p && xcodebuild test -project /Users/lucaspfingstenplanells/Desktop/YC-hack/TacNet.xcodeproj -scheme TacNet -destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' -only-testing:TacNetUITests/TacNetUISmokeTests/testPTTButtonLongPressOnRealMainTabDispatchesToViewModel -only-testing:TacNetUITests/TacNetUISmokeTests/testPTTButtonGestureOnRealMainTabNoGateTimeoutAcrossRepeatedPresses -resultBundlePath /ptt-gesture-tests.xcresult 2>&1 | tee /xcodebuild-ptt.log", + "exitCode": 0, + "observation": "Executed 2 targeted UI tests with 0 failures and '** TEST SUCCEEDED **'." + }, + { + "command": "xcrun xcresulttool export attachments --path /ptt-gesture-tests.xcresult --output-path /attachments", + "exitCode": 0, + "observation": "Exported 6 attachments (4 screenshots, 2 text logs) plus manifest.json for the two targeted tests." + }, + { + "command": "python3 evidence check (timeout string count + pass-line extraction)", + "exitCode": 0, + "observation": "timeout_count=0 for xcodebuild log and both captured PTT logs; pass lines for both targeted tests extracted into ptt-evidence-summary.txt." + } + ], + "frictions": [ + { + "description": "xcodebuild emitted non-fatal simulator/test-runner noise lines (IDELaunchParametersSnapshot debugger-version warning and duplicate UIAccessibilityLoaderWebShared class warning).", + "resolved": true, + "resolution": "No action required; tests continued and passed.", + "affectedAssertions": [ + "VAL-PTT-002", + "VAL-PTT-003" + ] + } + ], + "blockers": [], + "toolsUsed": [ + "xcodebuild", + "xcrun xcresulttool", + "python3" + ], + "summary": "Validated VAL-PTT-002 and VAL-PTT-003 on the real Main-tab long-press path via targeted TacNetUITests. Both assertions passed with concrete [PTT] log evidence and zero occurrences of 'Gesture: System gesture gate timed out'." +} diff --git a/.factory/validation/ptt-gesture-fix/user-testing/synthesis.json b/.factory/validation/ptt-gesture-fix/user-testing/synthesis.json new file mode 100644 index 00000000..4d47d52b --- /dev/null +++ b/.factory/validation/ptt-gesture-fix/user-testing/synthesis.json @@ -0,0 +1,19 @@ +{ + "milestone": "ptt-gesture-fix", + "round": 1, + "status": "pass", + "assertionsSummary": { + "total": 2, + "passed": 2, + "failed": 0, + "blocked": 0 + }, + "passedAssertions": [ + "VAL-PTT-002", + "VAL-PTT-003" + ], + "failedAssertions": [], + "blockedAssertions": [], + "appliedUpdates": [], + "previousRound": null +} diff --git a/.factory/validation/test-and-fix/scrutiny/reviews/extend-ui-smoke-bootstrap-and-settings.json b/.factory/validation/test-and-fix/scrutiny/reviews/extend-ui-smoke-bootstrap-and-settings.json new file mode 100644 index 00000000..3a0f6412 --- /dev/null +++ b/.factory/validation/test-and-fix/scrutiny/reviews/extend-ui-smoke-bootstrap-and-settings.json @@ -0,0 +1,21 @@ +{ + "featureId": "extend-ui-smoke-bootstrap-and-settings", + "reviewedAt": "2026-04-18T07:33:07.797139Z", + "commitId": "2516850c1757da4402b4af51e305fd496c5d9be0", + "transcriptSkeletonReviewed": true, + "diffReviewed": true, + "status": "pass", + "codeReview": { + "summary": "Pass. The fix feature directly addresses both prior scrutiny findings from simulator-ui-smoke-walkthrough-and-fix: it adds a bootstrap-gate UI test that launches without --ui-test-skip-download (using a deterministic fixture), and it adds explicit organiser-vs-participant Settings affordance assertions for VAL-UI-011.", + "issues": [] + }, + "sharedStateObservations": [ + { + "area": "knowledge", + "observation": "Shared UI-test reference docs are now stale: new launch arguments/routes added by this fix are not documented in .factory/library/ui-tests.md.", + "evidence": "Implementation adds `UITestMode.downloadFixture`, `UITestMode.role`, and `--ui-test-route=settings` host in TacNet/Views/ContentView.swift (lines 34, 41, 214, 227, 3379), but .factory/library/ui-tests.md only documents `--ui-test-skip-download` and `--ui-test-route=pin-entry` (lines 12 and 16)." + } + ], + "addressesFailureFrom": "/Users/lucaspfingstenplanells/Desktop/YC-hack/.factory/validation/test-and-fix/scrutiny/reviews/simulator-ui-smoke-walkthrough-and-fix.json", + "summary": "Pass with 0 blocking issues. Evidence from original feature diffs (57f1548/5287ef6) and fix diff (2516850) plus the fix transcript skeleton shows the previous blocking coverage gap is closed and the non-blocking settings-affordance gap is now explicitly tested." +} diff --git a/.factory/validation/test-and-fix/scrutiny/reviews/fix-model-download-non-zip-integrity.json b/.factory/validation/test-and-fix/scrutiny/reviews/fix-model-download-non-zip-integrity.json new file mode 100644 index 00000000..3bd17ac9 --- /dev/null +++ b/.factory/validation/test-and-fix/scrutiny/reviews/fix-model-download-non-zip-integrity.json @@ -0,0 +1,15 @@ +{ + "featureId": "fix-model-download-non-zip-integrity", + "reviewedAt": "2026-04-18T07:32:58Z", + "commitId": "e8b0db1dbf949b87f3ef3765ae374a64fc1feb0c", + "transcriptSkeletonReviewed": true, + "diffReviewed": true, + "status": "pass", + "codeReview": { + "summary": "Reviewed the original failing feature diff and this fix diff; the production non-zip integrity hole is now closed via explicit invalidArchive classification while test fixtures retain an opt-in non-zip path.", + "issues": [] + }, + "sharedStateObservations": [], + "addressesFailureFrom": "/Users/lucaspfingstenplanells/Desktop/YC-hack/.factory/validation/test-and-fix/scrutiny/reviews/fix-model-download-subsystem.json", + "summary": "Pass. The fix commit (e8b0db1dbf949b87f3ef3765ae374a64fc1feb0c) adequately resolves the prior blocking finding from fix-model-download-subsystem by rejecting non-zip payloads in production mode before sentinel promotion, preserving gate closure, and adding targeted regression tests for both production and test-mode behavior." +} diff --git a/.factory/validation/test-and-fix/scrutiny/reviews/fix-model-download-subsystem.json b/.factory/validation/test-and-fix/scrutiny/reviews/fix-model-download-subsystem.json new file mode 100644 index 00000000..16bd2633 --- /dev/null +++ b/.factory/validation/test-and-fix/scrutiny/reviews/fix-model-download-subsystem.json @@ -0,0 +1,33 @@ +{ + "featureId": "fix-model-download-subsystem", + "reviewedAt": "2026-04-18T07:02:37Z", + "commitId": "15e28247a458f8c3852d60fce73b4827789bc3b2", + "transcriptSkeletonReviewed": true, + "diffReviewed": true, + "status": "fail", + "codeReview": { + "summary": "The commit implements the intended interruption/resume/gating flow in ModelDownloadService, but it introduces a blocking integrity regression: any non-zip payload is treated as a successful model install and completion is persisted.", + "issues": [ + { + "file": "TacNet/Services/Cactus.swift", + "line": 1066, + "severity": "blocking", + "description": "The non-zip branch installs any payload without validating size or format, then marks the download complete (completionKey=true at line 1088). A tiny/invalid HTTP 200 body (e.g. error page) would unlock tactical features and persist a bad artifact instead of surfacing a network/size-mismatch error." + } + ] + }, + "sharedStateObservations": [ + { + "area": "skills", + "observation": "The ios-worker skill's step 6 states 'All tests must pass', but this mission intentionally had known failures split across features; the worker correctly reported unrelated remaining failures and still completed the feature. The skill should document baseline-delta handling for multi-feature fix missions.", + "evidence": ".factory/skills/ios-worker/SKILL.md (Step 6) vs handoff verification in mission handoff file 2026-04-18T06-11-28-675Z__fix-model-download-subsystem__942874ae-914b-40c7-bc51-6383f736d611.json (full suite still had the separate PTT failure)." + }, + { + "area": "conventions", + "observation": "Feature commit isolation is unclear when mission starts from a dirty tree. This feature commit included broad unrelated file changes (BluetoothMeshService/ContentView) while scoped to ModelDownload/AppBootstrap, making review noisier and coupling unrelated behavior changes to a subsystem fix.", + "evidence": "git show --stat 15e28247a458f8c3852d60fce73b4827789bc3b2 shows 4 files changed including TacNet/Services/BluetoothMeshService.swift and TacNet/Views/ContentView.swift, beyond the scoped ModelDownload fix in Cactus.swift." + } + ], + "addressesFailureFrom": null, + "summary": "Fail: although the targeted resume/interruption test failures were addressed, the new non-zip success path can falsely mark invalid downloads as complete, which is a correctness risk in the model-download subsystem." +} diff --git a/.factory/validation/test-and-fix/scrutiny/reviews/fix-ptt-mesh-disconnected-gating.json b/.factory/validation/test-and-fix/scrutiny/reviews/fix-ptt-mesh-disconnected-gating.json new file mode 100644 index 00000000..1b619b01 --- /dev/null +++ b/.factory/validation/test-and-fix/scrutiny/reviews/fix-ptt-mesh-disconnected-gating.json @@ -0,0 +1,21 @@ +{ + "featureId": "fix-ptt-mesh-disconnected-gating", + "reviewedAt": "2026-04-18T07:00:15Z", + "commitId": "b59fdcb", + "transcriptSkeletonReviewed": true, + "diffReviewed": true, + "status": "pass", + "codeReview": { + "summary": "The implementation in TacNet/Views/ContentView.swift correctly gates PTT when no mesh peers are connected: it exits before audio capture, keeps `pttState` idle, sets the exact required disconnection error string, and adds the requested rejected-path NSLog while preserving the existing connected-path recording NSLog. The connected path and related state-machine behavior remain intact, and no blocking or non-blocking code defects were identified in this feature diff.", + "issues": [] + }, + "sharedStateObservations": [ + { + "area": "skills", + "observation": "There is a procedure/enforcement mismatch: the ios-worker skill requires a full `xcodebuild build` step, but this worker session only ran targeted/full tests and still marked `skillFeedback.followedProcedure` as true.", + "evidence": ".factory/skills/ios-worker/SKILL.md (Work Procedure step 5 requires running `xcodebuild build`); worker transcript skeleton for session 7f26a86e-7e92-4f8d-9d24-2460a35c88ec shows only `xcodebuild test` commands before commit and EndFeatureRun." + } + ], + "addressesFailureFrom": null, + "summary": "Review passed. The PTT disconnected-mesh gating fix is correctly implemented and aligned with the feature contract; no code issues found in the reviewed commit." +} diff --git a/.factory/validation/test-and-fix/scrutiny/reviews/simulator-ui-smoke-walkthrough-and-fix.json b/.factory/validation/test-and-fix/scrutiny/reviews/simulator-ui-smoke-walkthrough-and-fix.json new file mode 100644 index 00000000..bddf33c1 --- /dev/null +++ b/.factory/validation/test-and-fix/scrutiny/reviews/simulator-ui-smoke-walkthrough-and-fix.json @@ -0,0 +1,34 @@ +{ + "featureId": "simulator-ui-smoke-walkthrough-and-fix", + "reviewedAt": "2026-04-18T07:01:59.691954Z", + "commitId": "5287ef6", + "transcriptSkeletonReviewed": true, + "diffReviewed": true, + "status": "fail", + "codeReview": { + "summary": "The feature adds substantial XCUITest and accessibility infrastructure (primarily in commit 57f1548, with follow-up docs/services in 5287ef6), but the automated smoke suite does not actually exercise the bootstrap/download-progress path that the feature contract explicitly requires.", + "issues": [ + { + "file": "TacNetUITests/TacNetUITests.swift", + "line": 30, + "severity": "blocking", + "description": "`makeApp()` always injects `--ui-test-skip-download`, and all non-route tests call this helper, so the XCUITest walkthrough never executes the real bootstrap/download-progress screen. This misses the feature requirement to cover the bootstrap/download gate path in the automated walkthrough." + }, + { + "file": "TacNetUITests/TacNetUITests.swift", + "line": 319, + "severity": "non_blocking", + "description": "The Settings-tab check only asserts that `tacnet.settings.root` renders; it does not verify key controls (e.g., Release Role / organiser-only affordances), so claimed VAL-UI-011 coverage is weaker than specified in the validation contract." + } + ] + }, + "sharedStateObservations": [ + { + "area": "conventions", + "observation": "Feature handoff commit pointer is ambiguous for reviewers: handoff `commitId` is `5287ef6` (docs/services only), while the core implementation diff for this feature is in the immediate parent commit `57f1548`.", + "evidence": "Handoff file `/Users/lucaspfingstenplanells/.factory/missions/68f59012-fcda-4f36-a993-558e770a5762/handoffs/2026-04-18T06-45-45-667Z__simulator-ui-smoke-walkthrough-and-fix__006b1307-f022-4a6f-8e72-0826bb8b0ba3.json` sets `commitId: 5287ef6`; `git show --stat 5287ef6` shows only `.factory/library/ui-tests.md` and `.factory/services.yaml`, while transcript skeleton contains `git log -1 --oneline` output for `57f1548 Add TacNetUITests XCUITest target and Simulator smoke walkthrough`." + } + ], + "addressesFailureFrom": null, + "summary": "Fail: the feature infrastructure is largely in place, but automated smoke coverage skips the bootstrap/download-progress path that this feature explicitly required, so implementation completeness is not met." +} diff --git a/.factory/validation/test-and-fix/scrutiny/reviews/warnings-cleanup-and-final-regression.json b/.factory/validation/test-and-fix/scrutiny/reviews/warnings-cleanup-and-final-regression.json new file mode 100644 index 00000000..d1f21d77 --- /dev/null +++ b/.factory/validation/test-and-fix/scrutiny/reviews/warnings-cleanup-and-final-regression.json @@ -0,0 +1,15 @@ +{ + "featureId": "warnings-cleanup-and-final-regression", + "reviewedAt": "2026-04-18T07:00:29Z", + "commitId": "72e3778c2ebcb605278d27b343ffd18df71f4f60", + "transcriptSkeletonReviewed": true, + "diffReviewed": true, + "status": "pass", + "codeReview": { + "summary": "Reviewed the feature handoff, transcript skeleton, and commit diff for the warnings cleanup. The implementation addresses all required warning sites in TacNet source, preserves existing behavior/logging patterns, and aligns with the feature’s expected regression evidence (zero TacNet-source warnings, successful clean build, and full unit+UI test pass). No actionable code defects were identified in the changed lines.", + "issues": [] + }, + "sharedStateObservations": [], + "addressesFailureFrom": null, + "summary": "PASS — The warnings-cleanup-and-final-regression feature is correctly implemented for commit 72e3778c2ebcb605278d27b343ffd18df71f4f60. Diff and transcript evidence support that TacNet-source warnings were eliminated, upstream cactus.framework warnings were explicitly allowlisted in mission AGENTS.md, and full regression (119 unit + 8 UI tests) succeeded." +} diff --git a/.factory/validation/test-and-fix/scrutiny/synthesis.json b/.factory/validation/test-and-fix/scrutiny/synthesis.json new file mode 100644 index 00000000..51b1610b --- /dev/null +++ b/.factory/validation/test-and-fix/scrutiny/synthesis.json @@ -0,0 +1,39 @@ +{ + "milestone": "test-and-fix", + "round": 2, + "status": "pass", + "validatorsRun": { + "test": { + "passed": true, + "command": "xcodebuild test -project TacNet.xcodeproj -scheme TacNet -destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' (TacNetUITests) and xcodebuild test -project TacNet.xcodeproj -scheme TacNet -destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' -only-testing:TacNetTests", + "exitCode": 0 + }, + "typecheck": { + "passed": true, + "command": "xcodebuild build -project TacNet.xcodeproj -scheme TacNet -destination 'platform=iOS Simulator,name=iPhone 17,OS=latest'", + "exitCode": 0 + }, + "lint": { + "passed": true, + "command": "xcodebuild clean build -project TacNet.xcodeproj -scheme TacNet -destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' (non-upstream warning check from /tmp/tacnet-clean-build-round2.log: ': warning:' lines excluding 'cactus.framework' = 0)", + "exitCode": 0 + } + }, + "reviewsSummary": { + "total": 2, + "passed": 2, + "failed": 0, + "failedFeatures": [] + }, + "blockingIssues": [], + "appliedUpdates": [ + { + "target": "library", + "description": "Updated .factory/library/ui-tests.md with new UI-test launch arguments and settings route host usage added by the fix feature.", + "sourceFeature": "extend-ui-smoke-bootstrap-and-settings" + } + ], + "suggestedGuidanceUpdates": [], + "rejectedObservations": [], + "previousRound": ".factory/validation/test-and-fix/scrutiny/synthesis.round1.json" +} diff --git a/.factory/validation/test-and-fix/scrutiny/synthesis.round1.json b/.factory/validation/test-and-fix/scrutiny/synthesis.round1.json new file mode 100644 index 00000000..588684bb --- /dev/null +++ b/.factory/validation/test-and-fix/scrutiny/synthesis.round1.json @@ -0,0 +1,60 @@ +{ + "milestone": "test-and-fix", + "round": 1, + "status": "fail", + "validatorsRun": { + "test": { + "passed": true, + "command": "xcodebuild test -project TacNet.xcodeproj -scheme TacNet -destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' -only-testing:TacNetTests", + "exitCode": 0 + }, + "typecheck": { + "passed": true, + "command": "xcodebuild build -project TacNet.xcodeproj -scheme TacNet -destination 'platform=iOS Simulator,name=iPhone 17,OS=latest'", + "exitCode": 0 + }, + "lint": { + "passed": true, + "command": "xcodebuild clean build -project TacNet.xcodeproj -scheme TacNet -destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' (checked non-upstream warnings via grep ': warning:' | grep -v 'cactus.framework', count=0)", + "exitCode": 0 + } + }, + "reviewsSummary": { + "total": 4, + "passed": 2, + "failed": 2, + "failedFeatures": [ + "fix-model-download-subsystem", + "simulator-ui-smoke-walkthrough-and-fix" + ] + }, + "blockingIssues": [ + { + "featureId": "fix-model-download-subsystem", + "severity": "blocking", + "description": "TacNet/Services/Cactus.swift non-zip path accepts any payload as successful install and persists completion, allowing tiny/invalid HTTP 200 content to unlock tactical features and mark model ready." + }, + { + "featureId": "simulator-ui-smoke-walkthrough-and-fix", + "severity": "blocking", + "description": "TacNetUITests/TacNetUITests.swift makeApp() always injects --ui-test-skip-download, so automated smoke never exercises bootstrap/download-progress coverage required by the feature contract." + } + ], + "appliedUpdates": [], + "suggestedGuidanceUpdates": [ + { + "target": "skill:ios-worker", + "suggestion": "Clarify baseline-delta behavior for multi-feature fix missions and enforce step compliance reporting (including required build command execution).", + "evidence": "Multiple reviews reported procedure mismatches: feature fix-model-download-subsystem notes conflict between 'all tests must pass' wording and known mission baseline failures; feature fix-ptt-mesh-disconnected-gating transcript omitted required build step while reporting followedProcedure=true.", + "isSystemic": true + }, + { + "target": "AGENTS.md", + "suggestion": "Add commit-traceability guidance requiring handoff commitId to point to the primary implementation commit and requiring feature diffs to avoid unrelated changes when starting from a dirty tree.", + "evidence": "Feature simulator-ui-smoke-walkthrough-and-fix handoff points to docs/services commit 5287ef6 while core implementation is parent 57f1548; feature fix-model-download-subsystem review observed unrelated files bundled into the feature commit.", + "isSystemic": true + } + ], + "rejectedObservations": [], + "previousRound": null +} diff --git a/.factory/validation/test-and-fix/user-testing/flows/core-build.json b/.factory/validation/test-and-fix/user-testing/flows/core-build.json new file mode 100644 index 00000000..c5bbf7e1 --- /dev/null +++ b/.factory/validation/test-and-fix/user-testing/flows/core-build.json @@ -0,0 +1,212 @@ +{ + "groupId": "core-build", + "testedAt": "2026-04-18T07:48:40.105559+00:00", + "isolation": { + "lane": "single-validator", + "destination": "platform=iOS Simulator,name=iPhone 17,OS=latest", + "milestone": "test-and-fix", + "scope": "non-UI assertions only", + "businessLogicModified": false + }, + "toolsUsed": [ + "xcodebuild", + "grep", + "python3" + ], + "commandsRun": [ + { + "command": "xcodebuild test -project /Users/lucaspfingstenplanells/Desktop/YC-hack/TacNet.xcodeproj -scheme TacNet -destination 'platform=iOS Simulator,name=iPhone 17,OS=latest'", + "exitCode": 0, + "logRef": "test-and-fix/core-build/xcodebuild-test.log", + "note": "Default scheme test action executed TacNetUITests (10 tests)." + }, + { + "command": "xcodebuild test -project /Users/lucaspfingstenplanells/Desktop/YC-hack/TacNet.xcodeproj -scheme TacNet -destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' -only-testing:TacNetTests", + "exitCode": 0, + "logRef": "test-and-fix/core-build/xcodebuild-test-unit.log" + }, + { + "command": "xcodebuild test -project /Users/lucaspfingstenplanells/Desktop/YC-hack/TacNet.xcodeproj -scheme TacNet -destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' -only-testing:TacNetTests -only-testing:TacNetUITests", + "exitCode": 0, + "logRef": "test-and-fix/core-build/xcodebuild-test-all.log" + }, + { + "command": "xcodebuild clean build -project /Users/lucaspfingstenplanells/Desktop/YC-hack/TacNet.xcodeproj -scheme TacNet -destination 'platform=iOS Simulator,name=iPhone 17,OS=latest'", + "exitCode": 0, + "logRef": "test-and-fix/core-build/xcodebuild-clean-build.log" + } + ], + "assertions": [ + { + "id": "VAL-MD-001", + "title": "Monotonic progress with \u22655 intermediate callbacks and gate unlock", + "status": "pass", + "reason": "Target XCTest passed in TacNetTests.", + "evidence": { + "logRefs": [ + "test-and-fix/core-build/xcodebuild-test-unit.log", + "test-and-fix/core-build/test-assertions-snippet.txt" + ], + "testName": "testModelDownloadServiceReportsMonotonicProgressWithAtLeastFiveIntermediateCallbacksAndUnlocksGate" + } + }, + { + "id": "VAL-MD-002", + "title": "Features block during download and unlock within 3 seconds of completion", + "status": "pass", + "reason": "Target XCTest passed in TacNetTests.", + "evidence": { + "logRefs": [ + "test-and-fix/core-build/xcodebuild-test-unit.log", + "test-and-fix/core-build/test-assertions-snippet.txt" + ], + "testName": "testAppBootstrapViewModelBlocksTacticalFeaturesDuringDownloadAndUnlocksWithinThreeSeconds" + } + }, + { + "id": "VAL-MD-003", + "title": "Retry resumes using stored resume data (not a fresh attempt)", + "status": "pass", + "reason": "Target XCTest passed in TacNetTests.", + "evidence": { + "logRefs": [ + "test-and-fix/core-build/xcodebuild-test-unit.log", + "test-and-fix/core-build/test-assertions-snippet.txt" + ], + "testName": "testAppBootstrapViewModelRetryResumesInterruptedDownloadFromPriorPoint" + } + }, + { + "id": "VAL-MD-004", + "title": "ModelDownloadService resumes using stored resume data after interruption", + "status": "pass", + "reason": "Target XCTest passed in TacNetTests.", + "evidence": { + "logRefs": [ + "test-and-fix/core-build/xcodebuild-test-unit.log", + "test-and-fix/core-build/test-assertions-snippet.txt" + ], + "testName": "testModelDownloadServiceResumesUsingStoredResumeDataAfterInterruption" + } + }, + { + "id": "VAL-MD-005", + "title": "Interruption recovery produces interrupted-class error then succeeds on retry", + "status": "pass", + "reason": "Target XCTest passed in TacNetTests.", + "evidence": { + "logRefs": [ + "test-and-fix/core-build/xcodebuild-test-unit.log", + "test-and-fix/core-build/test-assertions-snippet.txt" + ], + "testName": "testCrossAreaModelDownloadInterruptionRecoveryThenCactusInitSucceeds" + } + }, + { + "id": "VAL-MD-006", + "title": "Cactus init is gated until download completes, then uses downloaded path", + "status": "pass", + "reason": "Target XCTest passed in TacNetTests.", + "evidence": { + "logRefs": [ + "test-and-fix/core-build/xcodebuild-test-unit.log", + "test-and-fix/core-build/test-assertions-snippet.txt" + ], + "testName": "testCactusModelInitializationIsGatedUntilDownloadCompletesThenUsesDownloadedPath" + } + }, + { + "id": "VAL-MD-007", + "title": "First-time journey \u2014 download \u2192 init \u2192 join \u2192 PTT \u2192 transcript delivery within 5s", + "status": "pass", + "reason": "Target XCTest passed in TacNetTests.", + "evidence": { + "logRefs": [ + "test-and-fix/core-build/xcodebuild-test-unit.log", + "test-and-fix/core-build/test-assertions-snippet.txt" + ], + "testName": "testCrossAreaFirstTimeJourneyDownloadInitJoinPTTAndTranscriptDeliveryWithinFiveSeconds" + } + }, + { + "id": "VAL-PTT-001", + "title": "PTT disabled and error shown when disconnected from mesh", + "status": "pass", + "reason": "Target XCTest passed in TacNetTests.", + "evidence": { + "logRefs": [ + "test-and-fix/core-build/xcodebuild-test-unit.log", + "test-and-fix/core-build/test-assertions-snippet.txt" + ], + "testName": "testMainViewModelPTTDisabledWhenDisconnectedAndShowsError" + } + }, + { + "id": "VAL-BH-001", + "title": "Clean build emits zero warnings", + "status": "pass", + "reason": "Clean build completed and non-cactus warning count is 0 (8 warnings are documented cactus.framework allowlist).", + "evidence": { + "logRefs": [ + "test-and-fix/core-build/xcodebuild-clean-build.log", + "test-and-fix/core-build/xcodebuild-clean-build.warnings-all.txt", + "test-and-fix/core-build/xcodebuild-clean-build.warnings-non-cactus.txt", + "test-and-fix/core-build/build-health-snippet.txt" + ], + "warningCountNonCactus": 0, + "warningCountAll": 8 + } + }, + { + "id": "VAL-BH-002", + "title": "Clean build succeeds with zero errors", + "status": "pass", + "reason": "xcodebuild clean build exited 0 and log includes BUILD SUCCEEDED.", + "evidence": { + "logRefs": [ + "test-and-fix/core-build/xcodebuild-clean-build.log", + "test-and-fix/core-build/xcodebuild-clean-build.exitcode", + "test-and-fix/core-build/build-health-snippet.txt" + ], + "exitCode": 0 + } + }, + { + "id": "VAL-REG-001", + "title": "Full test sweep \u2014 all tests pass", + "status": "pass", + "reason": "Combined test sweep passed: TacNetTests 121/0 failures and TacNetUITests 10/0 failures, command exit code 0.", + "evidence": { + "logRefs": [ + "test-and-fix/core-build/xcodebuild-test-all.log", + "test-and-fix/core-build/xcodebuild-test-all.exitcode", + "test-and-fix/core-build/test-full-sweep-snippet.txt" + ], + "unitTestsExecuted": 121, + "uiTestsExecuted": 10, + "failures": 0, + "exitCode": 0 + } + } + ], + "frictions": [ + { + "description": "Default `xcodebuild test` under this scheme/test-plan ran only TacNetUITests (10 tests), so explicit `-only-testing` selectors were required to validate non-UI assertions and full sweep coverage.", + "resolved": true, + "resolution": "Ran `-only-testing:TacNetTests` for core assertions and `-only-testing:TacNetTests -only-testing:TacNetUITests` for full sweep evidence.", + "affectedAssertions": [ + "VAL-MD-001", + "VAL-MD-002", + "VAL-MD-003", + "VAL-MD-004", + "VAL-MD-005", + "VAL-MD-006", + "VAL-MD-007", + "VAL-PTT-001", + "VAL-REG-001" + ] + } + ], + "blockers": [], + "summary": "Validated 11 assigned assertions: 11 passed, 0 failed, 0 blocked. Clean build succeeded (exit 0) with 0 non-cactus warnings; full test sweep passed (TacNetTests 121 + TacNetUITests 10, 0 failures)." +} diff --git a/.factory/validation/test-and-fix/user-testing/flows/ui-regression.json b/.factory/validation/test-and-fix/user-testing/flows/ui-regression.json new file mode 100644 index 00000000..83e41776 --- /dev/null +++ b/.factory/validation/test-and-fix/user-testing/flows/ui-regression.json @@ -0,0 +1,450 @@ +{ + "groupId": "ui-regression", + "testedAt": "2026-04-18T08:00:09Z", + "isolation": { + "lane": "single-validator", + "simulator": "iPhone 17 (platform=iOS Simulator,name=iPhone 17,OS=latest)", + "missionDir": "/Users/lucaspfingstenplanells/.factory/missions/68f59012-fcda-4f36-a993-558e770a5762", + "evidenceRoot": "test-and-fix/ui-regression", + "productLogicEdits": "none" + }, + "toolsUsed": [ + "xcodebuild test", + "xcodebuild clean build", + "xcrun simctl", + "xcrun xcresulttool", + "python3 (log/assertion analysis)" + ], + "commandsRun": [ + { + "command": "xcodebuild clean build -project TacNet.xcodeproj -scheme TacNet -destination \"platform=iOS Simulator,name=iPhone 17,OS=latest\"", + "exitCode": 0, + "log": "test-and-fix/ui-regression/val-reg-002-build.log" + }, + { + "command": "xcodebuild test -project TacNet.xcodeproj -scheme TacNet -destination \"platform=iOS Simulator,name=iPhone 17,OS=latest\" -resultBundlePath test-and-fix/ui-regression/val-reg-002-full-tests.xcresult", + "exitCode": 0, + "log": "test-and-fix/ui-regression/val-reg-002-full-test.log" + }, + { + "command": "xcodebuild test -project TacNet.xcodeproj -scheme TacNet -destination \"platform=iOS Simulator,name=iPhone 17,OS=latest\" -only-testing:TacNetUITests -resultBundlePath test-and-fix/ui-regression/ui-smoke-tests.xcresult", + "exitCode": 0, + "log": "test-and-fix/ui-regression/ui-smoke-test.log" + }, + { + "command": "xcrun xcresulttool export attachments --path test-and-fix/ui-regression/ui-smoke-tests.xcresult --output-path test-and-fix/ui-regression/ui-smoke-attachments", + "exitCode": 0, + "log": "test-and-fix/ui-regression/ui-smoke-attachments/manifest.json" + }, + { + "command": "simctl lifecycle sequence (boot/install/launch/screenshot/terminate/shutdown) scripted via python", + "exitCode": 0, + "log": "test-and-fix/ui-regression/simctl-sequence-second-run.json" + } + ], + "consoleRedFlagCheck": { + "strings": [ + "Fatal error:", + "Thread 1: EXC_", + "SIGABRT", + "Modifying state during view update", + "AttributeGraph: cycle detected", + "Unhandled error", + "Invalid frame dimension" + ], + "result": "pass", + "detailsFile": "test-and-fix/ui-regression/console-redflag-check.json" + }, + "assertions": [ + { + "id": "VAL-UI-001", + "title": "App launches in Simulator without crashing", + "status": "pass", + "steps": [ + { + "action": "Boot iPhone 17 simulator and install TacNet.app with simctl", + "expected": "Install succeeds", + "observed": "Install succeeded (exit 0)." + }, + { + "action": "Launch com.tacnet.app with --ui-test-skip-download and wait 15s", + "expected": "App remains alive with rendered UI", + "observed": "Launch succeeded; terminate-after-wait returned exit 0 (process alive after 15s)." + }, + { + "action": "Capture screenshot at t=5s", + "expected": "Non-blank rendered UI screenshot captured", + "observed": "Screenshot captured successfully." + } + ], + "evidence": { + "screenshots": [ + "test-and-fix/ui-regression/VAL-UI-001-launch-t5-second-run.png", + "test-and-fix/ui-regression/ui-smoke-attachments/launch-welcome_0_E9DD07A0-C739-457D-9F1E-5B293014390A.png", + "test-and-fix/ui-regression/ui-smoke-attachments/launch-after-15s_0_1223C478-5CDF-4313-9ECF-6D3ED608F9B8.png" + ], + "consoleErrors": "none of required red-flag strings; see test-and-fix/ui-regression/VAL-UI-001-simctl-console-second-run.log and test-and-fix/ui-regression/console-redflag-check.json", + "network": "N/A (no API network assertions for launch smoke)", + "processAliveProof": "test-and-fix/ui-regression/simctl-sequence-second-run.json" + }, + "issues": null + }, + { + "id": "VAL-UI-002", + "title": "Initial screen renders appropriately for bootstrap state", + "status": "pass", + "steps": [ + { + "action": "Run UI smoke test for bootstrap download gate fixture", + "expected": "Download gate renders with progress/gating copy and no blank state", + "observed": "Gate UI rendered; test passed." + }, + { + "action": "Run UI smoke test with --ui-test-skip-download", + "expected": "Welcome screen renders with affordances", + "observed": "Welcome screen rendered; test passed." + } + ], + "evidence": { + "screenshots": [ + "test-and-fix/ui-regression/ui-smoke-attachments/download-gate-initial_0_ADDAEBB8-73CB-4AA6-B02D-12EB11020669.png", + "test-and-fix/ui-regression/ui-smoke-attachments/download-gate-after-10s_0_25697431-7E67-4113-8D61-615FE9D5D37D.png", + "test-and-fix/ui-regression/ui-smoke-attachments/initial-welcome_0_A0C4DC91-5680-4E07-8E98-0A8B33437908.png" + ], + "consoleErrors": "none; see test-and-fix/ui-regression/ui-smoke-test.log and test-and-fix/ui-regression/console-redflag-check.json", + "network": "N/A" + }, + "issues": null + }, + { + "id": "VAL-UI-003", + "title": "Welcome / onboarding screen renders with Create and Join affordances", + "status": "pass", + "steps": [ + { + "action": "Open welcome screen in UI smoke", + "expected": "Create and Join buttons visible", + "observed": "Both affordances present and tappable." + }, + { + "action": "Tap Create then navigate back and tap Join", + "expected": "Reach Tree Builder then Network Scan without crash", + "observed": "Both navigations succeeded; test passed." + } + ], + "evidence": { + "screenshots": [ + "test-and-fix/ui-regression/ui-smoke-attachments/initial-welcome_0_A0C4DC91-5680-4E07-8E98-0A8B33437908.png", + "test-and-fix/ui-regression/ui-smoke-attachments/create-network-treebuilder_0_39B2A642-3759-455D-BAF5-C857EFFCC2D8.png", + "test-and-fix/ui-regression/ui-smoke-attachments/join-network-scan_0_5B0BC7F7-A832-45BF-9A33-CA50859536A4.png" + ], + "consoleErrors": "none; see test-and-fix/ui-regression/ui-smoke-test.log and test-and-fix/ui-regression/console-redflag-check.json", + "network": "N/A" + }, + "issues": null + }, + { + "id": "VAL-UI-004", + "title": "Tree Builder (organiser) \u2014 add/rename/remove flow without crash", + "status": "pass", + "steps": [ + { + "action": "Navigate to Tree Builder from Welcome", + "expected": "Tree Builder renders", + "observed": "Rendered successfully." + }, + { + "action": "Add child node Alpha, rename to Bravo, then remove", + "expected": "UI updates after each action without crash", + "observed": "All actions succeeded and UI updated; test passed." + } + ], + "evidence": { + "screenshots": [ + "test-and-fix/ui-regression/ui-smoke-attachments/treebuilder-empty_0_68E9953E-93F1-46DA-82F8-A69411BC37A9.png", + "test-and-fix/ui-regression/ui-smoke-attachments/treebuilder-after-add_0_8464BFFA-6713-4E4F-A2CE-0E8652EE958E.png", + "test-and-fix/ui-regression/ui-smoke-attachments/treebuilder-after-rename_0_2C6CBA73-DFB6-47DE-A0BA-10B2B63CFA37.png", + "test-and-fix/ui-regression/ui-smoke-attachments/treebuilder-after-remove_0_0638E1BE-FD5E-41F3-B984-72B238A1D07E.png" + ], + "consoleErrors": "none; see test-and-fix/ui-regression/ui-smoke-test.log and test-and-fix/ui-regression/console-redflag-check.json", + "network": "N/A" + }, + "issues": null + }, + { + "id": "VAL-UI-005", + "title": "Network Scan screen renders even when no networks are discoverable", + "status": "pass", + "steps": [ + { + "action": "Navigate Welcome -> Join Network", + "expected": "Network Scan view renders in simulator", + "observed": "Scan view rendered." + }, + { + "action": "Observe empty/rescan state", + "expected": "No crash/hang in no-BLE simulator context", + "observed": "Empty state and rescan interaction completed; test passed." + } + ], + "evidence": { + "screenshots": [ + "test-and-fix/ui-regression/ui-smoke-attachments/scan-empty_0_852CE598-76CE-4005-A83E-5E6490085D69.png", + "test-and-fix/ui-regression/ui-smoke-attachments/scan-after-rescan_0_D710BE5F-7D9B-4AA9-B5F8-C24A18837CEA.png" + ], + "consoleErrors": "none; see test-and-fix/ui-regression/ui-smoke-test.log and test-and-fix/ui-regression/console-redflag-check.json", + "network": "N/A" + }, + "issues": null + }, + { + "id": "VAL-UI-006", + "title": "PIN entry screen renders and accepts input", + "status": "pass", + "steps": [ + { + "action": "Launch UI host route pin-entry", + "expected": "PIN entry view renders", + "observed": "PIN field rendered." + }, + { + "action": "Enter 4 digits and submit", + "expected": "Input accepted and submission observed", + "observed": "Submitted value indicator rendered; test passed." + } + ], + "evidence": { + "screenshots": [ + "test-and-fix/ui-regression/ui-smoke-attachments/pin-entry_0_FCE26249-1AAB-4125-A8D5-460FD727763C.png", + "test-and-fix/ui-regression/ui-smoke-attachments/pin-entry-submitted_0_F5B3FBBF-F0ED-4675-A00E-2FD71E2988B4.png" + ], + "consoleErrors": "none; see test-and-fix/ui-regression/ui-smoke-test.log and test-and-fix/ui-regression/console-redflag-check.json", + "network": "N/A" + }, + "issues": null + }, + { + "id": "VAL-UI-007", + "title": "Role Selection screen renders tree hierarchy with claim controls", + "status": "pass", + "steps": [ + { + "action": "Seed network in UI smoke helper and publish tree", + "expected": "Role selection screen appears", + "observed": "Role selection rendered." + }, + { + "action": "Verify seeded node and claim control availability", + "expected": "Seeded hierarchy visible and claim path operable", + "observed": "Alpha node shown; flow progressed to claim step in tab test." + } + ], + "evidence": { + "screenshots": [ + "test-and-fix/ui-regression/ui-smoke-attachments/role-selection_0_74E5CC16-289F-4772-99DC-8CAC76F251D9.png", + "test-and-fix/ui-regression/ui-smoke-attachments/after-claim-tap_0_5DC77A7D-2E4D-4D46-9BF9-20174F2DC588.png" + ], + "consoleErrors": "none; see test-and-fix/ui-regression/ui-smoke-test.log and test-and-fix/ui-regression/console-redflag-check.json", + "network": "N/A" + }, + "issues": null + }, + { + "id": "VAL-UI-008", + "title": "Main tab renders live feed and PTT button responds", + "status": "pass", + "steps": [ + { + "action": "Reach main tab after seeded claim", + "expected": "Main view and PTT control render", + "observed": "Main root and PTT control found." + }, + { + "action": "Press-and-hold PTT control", + "expected": "Interaction completes without freeze/crash", + "observed": "Hold action succeeded; app stayed foreground." + } + ], + "evidence": { + "screenshots": [ + "test-and-fix/ui-regression/ui-smoke-attachments/tab-main_0_28CC2BD7-B0C0-44F7-A162-F669C8CE41FC.png", + "test-and-fix/ui-regression/ui-smoke-attachments/tab-main-after-ptt_0_AE7FE5D7-AC34-473A-B2CB-A48FA9AEEF86.png" + ], + "consoleErrors": "none; see test-and-fix/ui-regression/ui-smoke-test.log and test-and-fix/ui-regression/console-redflag-check.json", + "network": "N/A" + }, + "issues": null + }, + { + "id": "VAL-UI-009", + "title": "Tree tab renders hierarchy with status indicators", + "status": "pass", + "steps": [ + { + "action": "Switch to Tree View tab", + "expected": "Tree tab content renders", + "observed": "Tree root rendered with seeded hierarchy." + } + ], + "evidence": { + "screenshots": [ + "test-and-fix/ui-regression/ui-smoke-attachments/tab-tree_0_82B2E436-367C-44F5-A0BB-F57FED79D319.png" + ], + "consoleErrors": "none; see test-and-fix/ui-regression/ui-smoke-test.log and test-and-fix/ui-regression/console-redflag-check.json", + "network": "N/A" + }, + "issues": null + }, + { + "id": "VAL-UI-010", + "title": "Data Flow tab renders INCOMING / PROCESSING / OUTGOING sections", + "status": "pass", + "steps": [ + { + "action": "Switch to Data Flow tab", + "expected": "Data Flow root with all three section headers", + "observed": "INCOMING/PROCESSING/OUTGOING headers verified; test passed." + } + ], + "evidence": { + "screenshots": [ + "test-and-fix/ui-regression/ui-smoke-attachments/tab-dataflow_0_688CB64D-BB17-4058-9888-400BE0142D31.png" + ], + "consoleErrors": "none; see test-and-fix/ui-regression/ui-smoke-test.log and test-and-fix/ui-regression/console-redflag-check.json", + "network": "N/A" + }, + "issues": null + }, + { + "id": "VAL-UI-011", + "title": "Settings tab renders with release-role and organiser-only actions", + "status": "pass", + "steps": [ + { + "action": "Launch settings host as organiser", + "expected": "Edit Tree + Promote + Release Role visible", + "observed": "All organiser controls visible." + }, + { + "action": "Launch settings host as participant", + "expected": "Release Role visible; organiser-only controls hidden", + "observed": "Participant controls matched expectation." + } + ], + "evidence": { + "screenshots": [ + "test-and-fix/ui-regression/ui-smoke-attachments/settings-organiser_0_2C478707-2AEE-4D5B-B6AB-5DB69224177C.png", + "test-and-fix/ui-regression/ui-smoke-attachments/settings-participant_0_B5B024CE-87A0-430F-8F78-6DA5D8BEE730.png" + ], + "consoleErrors": "none; see test-and-fix/ui-regression/ui-smoke-test.log and test-and-fix/ui-regression/console-redflag-check.json", + "network": "N/A" + }, + "issues": null + }, + { + "id": "VAL-UI-012", + "title": "Tab navigation switches between all 4 tabs without state loss or crash", + "status": "pass", + "steps": [ + { + "action": "Cycle Main -> Tree -> Data Flow -> Settings -> repeat", + "expected": "All transitions responsive and stable", + "observed": "Full tab cycle completed without crash; app remained foreground." + } + ], + "evidence": { + "screenshots": [ + "test-and-fix/ui-regression/ui-smoke-attachments/tab-main_0_28CC2BD7-B0C0-44F7-A162-F669C8CE41FC.png", + "test-and-fix/ui-regression/ui-smoke-attachments/tab-tree_0_82B2E436-367C-44F5-A0BB-F57FED79D319.png", + "test-and-fix/ui-regression/ui-smoke-attachments/tab-dataflow_0_688CB64D-BB17-4058-9888-400BE0142D31.png", + "test-and-fix/ui-regression/ui-smoke-attachments/tab-settings_0_69D7C9BE-4E79-446C-ADC0-5C1F92A817C6.png", + "test-and-fix/ui-regression/ui-smoke-attachments/tab-cycle-final_0_83E95BE5-91AC-438D-B32A-57397392DA3A.png" + ], + "consoleErrors": "none; see test-and-fix/ui-regression/ui-smoke-test.log and test-and-fix/ui-regression/console-redflag-check.json", + "network": "N/A" + }, + "issues": null + }, + { + "id": "VAL-UI-013", + "title": "No SwiftUI runtime errors or console red-flags during full smoke walk", + "status": "pass", + "steps": [ + { + "action": "Scan smoke and simctl console logs for required red-flag strings", + "expected": "Zero matches for all red-flag strings", + "observed": "All red-flag counts were zero across checked logs." + } + ], + "evidence": { + "screenshots": [ + "test-and-fix/ui-regression/ui-smoke-attachments/tab-cycle-final_0_83E95BE5-91AC-438D-B32A-57397392DA3A.png" + ], + "consoleErrors": "explicit red-flag check at test-and-fix/ui-regression/console-redflag-check.json (all counts 0 for Fatal error:, Thread 1: EXC_, SIGABRT, Modifying state during view update, AttributeGraph: cycle detected, Unhandled error, Invalid frame dimension)", + "network": "N/A", + "fullConsoleLogs": [ + "test-and-fix/ui-regression/ui-smoke-test.log", + "test-and-fix/ui-regression/val-reg-002-full-test.log", + "test-and-fix/ui-regression/VAL-UI-001-simctl-console-second-run.log" + ] + }, + "issues": null + }, + { + "id": "VAL-REG-002", + "title": "End-to-end pipeline \u2014 clean build, full tests, simulator walkthrough all green", + "status": "pass", + "steps": [ + { + "action": "Run clean build on iPhone 17 simulator destination", + "expected": "BUILD SUCCEEDED with zero non-allowlisted warnings", + "observed": "BUILD SUCCEEDED; warning_non_cactus=0." + }, + { + "action": "Run full test suite", + "expected": "0 failures and >=119 tests", + "observed": "Unit suite executed 121 tests (0 failures) and UI suite executed 10 tests (0 failures) in same run." + }, + { + "action": "Run simulator walkthrough evidence (UI smoke + simctl launch/install lifecycle)", + "expected": "Onboarding and tab smoke complete without crash", + "observed": "UI smoke tests passed and simctl launch evidence captured." + } + ], + "evidence": { + "screenshots": [ + "test-and-fix/ui-regression/VAL-UI-001-launch-t5-second-run.png", + "test-and-fix/ui-regression/ui-smoke-attachments/tab-cycle-final_0_83E95BE5-91AC-438D-B32A-57397392DA3A.png" + ], + "consoleErrors": "none; see test-and-fix/ui-regression/console-redflag-check.json", + "network": "N/A", + "buildLog": "test-and-fix/ui-regression/val-reg-002-build.log", + "testLog": "test-and-fix/ui-regression/val-reg-002-full-test.log", + "smokeLog": "test-and-fix/ui-regression/ui-smoke-test.log", + "analysis": "test-and-fix/ui-regression/analysis-summary.json" + }, + "issues": null + } + ], + "frictions": [ + { + "description": "A preliminary liveness check using `simctl spawn booted ps -p ` failed with \"No such file or directory\" in simulator shell.", + "resolved": true, + "resolution": "Used a stronger liveness proof: successful `simctl terminate` after 15s wait (exit 0) in second-run sequence.", + "affectedAssertions": [ + "VAL-UI-001" + ] + }, + { + "description": "Pre-launch `simctl terminate com.tacnet.app` returned \"found nothing to terminate\" when app was not running.", + "resolved": true, + "resolution": "Treated as benign precondition and proceeded to install/launch flow.", + "affectedAssertions": [ + "VAL-UI-001", + "VAL-REG-002" + ] + } + ], + "blockers": [], + "summary": "Validated 14 assigned assertions (VAL-UI-001..013, VAL-REG-002): 14 passed, 0 failed, 0 blocked. Pipeline checks green with BUILD SUCCEEDED, full tests (121 unit + 10 UI) passing, simulator smoke evidence captured, and red-flag console strings absent." +} \ No newline at end of file diff --git a/.factory/validation/test-and-fix/user-testing/synthesis.json b/.factory/validation/test-and-fix/user-testing/synthesis.json new file mode 100644 index 00000000..10ba6e82 --- /dev/null +++ b/.factory/validation/test-and-fix/user-testing/synthesis.json @@ -0,0 +1,53 @@ +{ + "milestone": "test-and-fix", + "round": 1, + "status": "pass", + "assertionsSummary": { + "total": 25, + "passed": 25, + "failed": 0, + "blocked": 0 + }, + "passedAssertions": [ + "VAL-MD-001", + "VAL-MD-002", + "VAL-MD-003", + "VAL-MD-004", + "VAL-MD-005", + "VAL-MD-006", + "VAL-MD-007", + "VAL-PTT-001", + "VAL-BH-001", + "VAL-BH-002", + "VAL-REG-001", + "VAL-UI-001", + "VAL-UI-002", + "VAL-UI-003", + "VAL-UI-004", + "VAL-UI-005", + "VAL-UI-006", + "VAL-UI-007", + "VAL-UI-008", + "VAL-UI-009", + "VAL-UI-010", + "VAL-UI-011", + "VAL-UI-012", + "VAL-UI-013", + "VAL-REG-002" + ], + "failedAssertions": [], + "blockedAssertions": [], + "appliedUpdates": [ + { + "target": "user-testing.md", + "description": "Added \"Flow Validator Guidance: iOS Simulator\" isolation rules for subagent safety and output boundaries.", + "source": "setup" + }, + { + "target": "user-testing.md", + "description": "Documented that explicit -only-testing selectors are required to reliably include TacNetTests in validation runs.", + "source": "flow-report" + } + ], + "previousRound": null +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..cb64c9d6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.DS_Store + +# Parakeet model weights (bundled in app, too large for git) +TacNet/Resources/ParakeetCTC/* +!TacNet/Resources/ParakeetCTC/.gitkeep diff --git a/AryaaBluetoothMesh/ios/MeshNode.xcodeproj/project.pbxproj b/AryaaBluetoothMesh/ios/MeshNode.xcodeproj/project.pbxproj new file mode 100644 index 00000000..ffa30222 --- /dev/null +++ b/AryaaBluetoothMesh/ios/MeshNode.xcodeproj/project.pbxproj @@ -0,0 +1,333 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 2C5EB5211A3783A603ACC262 /* MeshCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B3D9D836E3DA3F39B7A419A /* MeshCache.swift */; }; + 3F40690ABBCB0F18FAACC3A8 /* MeshCentral.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA35AE9D2BBBE3A46684A26 /* MeshCentral.swift */; }; + 47E050BD30A1DDE3452FD4E3 /* MeshConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E8F6F293E1385061DA187AE /* MeshConstants.swift */; }; + 896D4F4398F31BF2F8A8DC1D /* MeshMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60974A85F2A6A5C81E1246A1 /* MeshMessage.swift */; }; + A67AA2B1A2D111A6208B8D57 /* MeshManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30D29A78DC6BCA46769DFAFB /* MeshManager.swift */; }; + C9D9A4943A086F83A27EFFC9 /* MeshPeripheral.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0350DACFB7EF35C5C6E781 /* MeshPeripheral.swift */; }; + D940063243F8C733F6C6D5AD /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 682C14444F4CCC47633B6B84 /* ContentView.swift */; }; + EA1CD9826EA66BF3DAF2CF1D /* MeshNodeApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91A71F1D740DCC4B8988C230 /* MeshNodeApp.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 0CA35AE9D2BBBE3A46684A26 /* MeshCentral.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshCentral.swift; sourceTree = ""; }; + 0E8F6F293E1385061DA187AE /* MeshConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshConstants.swift; sourceTree = ""; }; + 1B3D9D836E3DA3F39B7A419A /* MeshCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshCache.swift; sourceTree = ""; }; + 27D5FC382B4C057B023CD302 /* MeshNode.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = MeshNode.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 30D29A78DC6BCA46769DFAFB /* MeshManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshManager.swift; sourceTree = ""; }; + 50461E41BEA855FBB9338A51 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + 60974A85F2A6A5C81E1246A1 /* MeshMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshMessage.swift; sourceTree = ""; }; + 682C14444F4CCC47633B6B84 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + 91A71F1D740DCC4B8988C230 /* MeshNodeApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshNodeApp.swift; sourceTree = ""; }; + AA0350DACFB7EF35C5C6E781 /* MeshPeripheral.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshPeripheral.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXGroup section */ + 14A2E91770A5E5CC1D269BD8 = { + isa = PBXGroup; + children = ( + A942E0ED20EAFD4EAF5E5EA9 /* MeshNode */, + B25223376C34F92612702A6C /* Products */, + ); + sourceTree = ""; + }; + A942E0ED20EAFD4EAF5E5EA9 /* MeshNode */ = { + isa = PBXGroup; + children = ( + 682C14444F4CCC47633B6B84 /* ContentView.swift */, + 50461E41BEA855FBB9338A51 /* Info.plist */, + 91A71F1D740DCC4B8988C230 /* MeshNodeApp.swift */, + E43011F0BF27E5B5082FEEC6 /* Mesh */, + ); + path = MeshNode; + sourceTree = ""; + }; + B25223376C34F92612702A6C /* Products */ = { + isa = PBXGroup; + children = ( + 27D5FC382B4C057B023CD302 /* MeshNode.app */, + ); + name = Products; + sourceTree = ""; + }; + E43011F0BF27E5B5082FEEC6 /* Mesh */ = { + isa = PBXGroup; + children = ( + 1B3D9D836E3DA3F39B7A419A /* MeshCache.swift */, + 0CA35AE9D2BBBE3A46684A26 /* MeshCentral.swift */, + 0E8F6F293E1385061DA187AE /* MeshConstants.swift */, + 30D29A78DC6BCA46769DFAFB /* MeshManager.swift */, + 60974A85F2A6A5C81E1246A1 /* MeshMessage.swift */, + AA0350DACFB7EF35C5C6E781 /* MeshPeripheral.swift */, + ); + path = Mesh; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 16F3AA0C18DFEF89BC11721D /* MeshNode */ = { + isa = PBXNativeTarget; + buildConfigurationList = D5A09DF317C0FD27D57CB18B /* Build configuration list for PBXNativeTarget "MeshNode" */; + buildPhases = ( + C82FD2BF38DFE4A9CFCB8472 /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = MeshNode; + packageProductDependencies = ( + ); + productName = MeshNode; + productReference = 27D5FC382B4C057B023CD302 /* MeshNode.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + B2515AEA521C750AB9C1E1F5 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1430; + TargetAttributes = { + 16F3AA0C18DFEF89BC11721D = { + ProvisioningStyle = Automatic; + }; + }; + }; + buildConfigurationList = 807597B4E40B8FA938305A2F /* Build configuration list for PBXProject "MeshNode" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + en, + ); + mainGroup = 14A2E91770A5E5CC1D269BD8; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = B25223376C34F92612702A6C /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 16F3AA0C18DFEF89BC11721D /* MeshNode */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXSourcesBuildPhase section */ + C82FD2BF38DFE4A9CFCB8472 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + D940063243F8C733F6C6D5AD /* ContentView.swift in Sources */, + 2C5EB5211A3783A603ACC262 /* MeshCache.swift in Sources */, + 3F40690ABBCB0F18FAACC3A8 /* MeshCentral.swift in Sources */, + 47E050BD30A1DDE3452FD4E3 /* MeshConstants.swift in Sources */, + A67AA2B1A2D111A6208B8D57 /* MeshManager.swift in Sources */, + 896D4F4398F31BF2F8A8DC1D /* MeshMessage.swift in Sources */, + EA1CD9826EA66BF3DAF2CF1D /* MeshNodeApp.swift in Sources */, + C9D9A4943A086F83A27EFFC9 /* MeshPeripheral.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 11B85B455EA9425724F53E11 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "DEBUG=1", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 2EC4F3B1596227681FC536B3 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_IDENTITY = "iPhone Developer"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = MeshNode/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 0.1; + PRODUCT_BUNDLE_IDENTIFIER = com.cactushack.MeshNode; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.9; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Debug; + }; + 376B81EFBD38F360E5A3D402 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_IDENTITY = "iPhone Developer"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = MeshNode/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 0.1; + PRODUCT_BUNDLE_IDENTIFIER = com.cactushack.MeshNode; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.9; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Release; + }; + 95D1320433B6497F2B2DC5FE /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 807597B4E40B8FA938305A2F /* Build configuration list for PBXProject "MeshNode" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 11B85B455EA9425724F53E11 /* Debug */, + 95D1320433B6497F2B2DC5FE /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + D5A09DF317C0FD27D57CB18B /* Build configuration list for PBXNativeTarget "MeshNode" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 2EC4F3B1596227681FC536B3 /* Debug */, + 376B81EFBD38F360E5A3D402 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; +/* End XCConfigurationList section */ + }; + rootObject = B2515AEA521C750AB9C1E1F5 /* Project object */; +} diff --git a/AryaaBluetoothMesh/ios/MeshNode.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/AryaaBluetoothMesh/ios/MeshNode.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/AryaaBluetoothMesh/ios/MeshNode.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/AryaaBluetoothMesh/ios/MeshNode.xcodeproj/project.xcworkspace/xcuserdata/aryaask.xcuserdatad/UserInterfaceState.xcuserstate b/AryaaBluetoothMesh/ios/MeshNode.xcodeproj/project.xcworkspace/xcuserdata/aryaask.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 00000000..eb12f8ed Binary files /dev/null and b/AryaaBluetoothMesh/ios/MeshNode.xcodeproj/project.xcworkspace/xcuserdata/aryaask.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/AryaaBluetoothMesh/ios/MeshNode.xcodeproj/xcuserdata/aryaask.xcuserdatad/xcschemes/xcschememanagement.plist b/AryaaBluetoothMesh/ios/MeshNode.xcodeproj/xcuserdata/aryaask.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 00000000..5692c6f0 --- /dev/null +++ b/AryaaBluetoothMesh/ios/MeshNode.xcodeproj/xcuserdata/aryaask.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,14 @@ + + + + + SchemeUserState + + MeshNode.xcscheme_^#shared#^_ + + orderHint + 0 + + + + diff --git a/AryaaBluetoothMesh/ios/MeshNode/ContentView.swift b/AryaaBluetoothMesh/ios/MeshNode/ContentView.swift new file mode 100644 index 00000000..59f3aaa6 --- /dev/null +++ b/AryaaBluetoothMesh/ios/MeshNode/ContentView.swift @@ -0,0 +1,106 @@ +import SwiftUI + +struct ContentView: View { + @EnvironmentObject var mesh: MeshManager + @State private var draft: String = "" + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + debugBar + Divider() + messageList + Divider() + composer + } + .navigationTitle("MeshNode") + .navigationBarTitleDisplayMode(.inline) + } + } + + private var debugBar: some View { + HStack(spacing: 8) { + stat("Peers", mesh.connectedPeerCount) + stat("Sent", mesh.sentCount) + stat("Recv", mesh.receivedCount) + stat("Fwd", mesh.forwardedCount) + stat("Dedup", mesh.dedupedCount) + } + .font(.caption.monospacedDigit()) + .padding(.horizontal) + .padding(.vertical, 8) + .background(Color(uiColor: .secondarySystemBackground)) + } + + private func stat(_ label: String, _ value: Int) -> some View { + VStack(spacing: 2) { + Text(label).foregroundStyle(.secondary) + Text("\(value)").bold() + } + .frame(maxWidth: .infinity) + } + + private var messageList: some View { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 8) { + ForEach(mesh.messages) { msg in + messageRow(msg) + .id(msg.id) + } + } + .padding() + } + .onChange(of: mesh.messages.count) { _, _ in + if let last = mesh.messages.last { + withAnimation { proxy.scrollTo(last.id, anchor: .bottom) } + } + } + } + } + + private func messageRow(_ msg: MeshMessage) -> some View { + let isSelf = msg.senderId == mesh.selfId + return HStack { + if isSelf { Spacer() } + VStack(alignment: isSelf ? .trailing : .leading, spacing: 2) { + Text(msg.payload) + .padding(8) + .background(isSelf ? Color.accentColor.opacity(0.2) + : Color(uiColor: .systemGray5)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + Text("\(shortId(msg.senderId)) · TTL \(msg.ttl)") + .font(.caption2) + .foregroundStyle(.secondary) + } + if !isSelf { Spacer() } + } + } + + private var composer: some View { + HStack { + TextField("Message", text: $draft, axis: .vertical) + .textFieldStyle(.roundedBorder) + .lineLimit(1...3) + .submitLabel(.send) + .onSubmit(sendDraft) + Button("Send", action: sendDraft) + .buttonStyle(.borderedProminent) + .disabled(draft.trimmingCharacters(in: .whitespaces).isEmpty) + } + .padding() + } + + private func sendDraft() { + mesh.send(text: draft) + draft = "" + } + + private func shortId(_ id: UUID) -> String { + String(id.uuidString.prefix(8)) + } +} + +#Preview { + ContentView().environmentObject(MeshManager()) +} diff --git a/AryaaBluetoothMesh/ios/MeshNode/Info.plist b/AryaaBluetoothMesh/ios/MeshNode/Info.plist new file mode 100644 index 00000000..800ca99c --- /dev/null +++ b/AryaaBluetoothMesh/ios/MeshNode/Info.plist @@ -0,0 +1,49 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + MeshNode + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.1 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + NSBluetoothAlwaysUsageDescription + MeshNode uses Bluetooth to communicate with nearby devices in the mesh network. + NSBluetoothPeripheralUsageDescription + MeshNode uses Bluetooth to communicate with nearby devices in the mesh network. + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + UILaunchScreen + + UIColorName + + + UIRequiredDeviceCapabilities + + armv7 + bluetooth-le + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + + + diff --git a/AryaaBluetoothMesh/ios/MeshNode/Mesh/MeshCache.swift b/AryaaBluetoothMesh/ios/MeshNode/Mesh/MeshCache.swift new file mode 100644 index 00000000..019ae5ea --- /dev/null +++ b/AryaaBluetoothMesh/ios/MeshNode/Mesh/MeshCache.swift @@ -0,0 +1,35 @@ +import Foundation + +final class MeshCache { + private let capacity: Int + private var set = Set() + private var order = [String]() + private let lock = NSLock() + + init(capacity: Int = MeshConstants.cacheSize) { + self.capacity = capacity + } + + private static func key(_ sender: UUID, _ msgId: UInt32) -> String { + "\(sender.uuidString):\(msgId)" + } + + func contains(sender: UUID, msgId: UInt32) -> Bool { + lock.lock(); defer { lock.unlock() } + return set.contains(Self.key(sender, msgId)) + } + + @discardableResult + func insert(sender: UUID, msgId: UInt32) -> Bool { + lock.lock(); defer { lock.unlock() } + let k = Self.key(sender, msgId) + if set.contains(k) { return false } + set.insert(k) + order.append(k) + if order.count > capacity { + let evict = order.removeFirst() + set.remove(evict) + } + return true + } +} diff --git a/AryaaBluetoothMesh/ios/MeshNode/Mesh/MeshCentral.swift b/AryaaBluetoothMesh/ios/MeshNode/Mesh/MeshCentral.swift new file mode 100644 index 00000000..33f1b15c --- /dev/null +++ b/AryaaBluetoothMesh/ios/MeshNode/Mesh/MeshCentral.swift @@ -0,0 +1,121 @@ +import CoreBluetooth +import Foundation + +protocol MeshCentralDelegate: AnyObject { + func central(connectedPeersChanged count: Int) +} + +/// Runs the Central role: scans for the mesh service UUID, connects to +/// discovered peers (up to maxOutbound), discovers each peer's inbox +/// characteristic, and writes outgoing/forwarded messages to connected peers. +final class MeshCentral: NSObject { + weak var delegate: MeshCentralDelegate? + + private var manager: CBCentralManager! + private var peers = [UUID: Peer]() + private var pending = [UUID: CBPeripheral]() + + final class Peer { + let peripheral: CBPeripheral + var inbox: CBCharacteristic? + var isConnected: Bool = false + init(peripheral: CBPeripheral) { self.peripheral = peripheral } + } + + func start() { + manager = CBCentralManager(delegate: self, queue: nil) + } + + var connectedCount: Int { + peers.values.filter { $0.isConnected && $0.inbox != nil }.count + } + + func broadcast(_ data: Data, excluding: String? = nil) { + for (id, peer) in peers { + guard peer.isConnected, let inbox = peer.inbox else { continue } + if id.uuidString == excluding { continue } + peer.peripheral.writeValue(data, for: inbox, type: .withoutResponse) + } + } + + private func startScanning() { + guard manager.state == .poweredOn else { return } + manager.scanForPeripherals( + withServices: [MeshConstants.serviceUUID], + options: [CBCentralManagerScanOptionAllowDuplicatesKey: false] + ) + } + + private func notifyCountChange() { + delegate?.central(connectedPeersChanged: connectedCount) + } +} + +extension MeshCentral: CBCentralManagerDelegate, CBPeripheralDelegate { + func centralManagerDidUpdateState(_ central: CBCentralManager) { + if central.state == .poweredOn { + startScanning() + } + } + + func centralManager(_ central: CBCentralManager, + didDiscover peripheral: CBPeripheral, + advertisementData: [String : Any], + rssi RSSI: NSNumber) { + let id = peripheral.identifier + if peers[id] != nil || pending[id] != nil { return } + if connectedCount >= MeshConstants.maxOutbound { + pending[id] = peripheral + return + } + let peer = Peer(peripheral: peripheral) + peers[id] = peer + central.connect(peripheral, options: nil) + } + + func centralManager(_ central: CBCentralManager, + didConnect peripheral: CBPeripheral) { + peripheral.delegate = self + peers[peripheral.identifier]?.isConnected = true + peripheral.discoverServices([MeshConstants.serviceUUID]) + } + + func centralManager(_ central: CBCentralManager, + didDisconnectPeripheral peripheral: CBPeripheral, + error: Error?) { + peers.removeValue(forKey: peripheral.identifier) + notifyCountChange() + if let (id, candidate) = pending.first { + pending.removeValue(forKey: id) + let peer = Peer(peripheral: candidate) + peers[id] = peer + central.connect(candidate, options: nil) + } else if !central.isScanning { + startScanning() + } + } + + func centralManager(_ central: CBCentralManager, + didFailToConnect peripheral: CBPeripheral, + error: Error?) { + peers.removeValue(forKey: peripheral.identifier) + } + + func peripheral(_ peripheral: CBPeripheral, + didDiscoverServices error: Error?) { + guard let services = peripheral.services else { return } + for service in services where service.uuid == MeshConstants.serviceUUID { + peripheral.discoverCharacteristics([MeshConstants.inboxUUID], for: service) + } + } + + func peripheral(_ peripheral: CBPeripheral, + didDiscoverCharacteristicsFor service: CBService, + error: Error?) { + guard let chars = service.characteristics else { return } + for c in chars where c.uuid == MeshConstants.inboxUUID { + peers[peripheral.identifier]?.inbox = c + notifyCountChange() + } + } +} diff --git a/AryaaBluetoothMesh/ios/MeshNode/Mesh/MeshConstants.swift b/AryaaBluetoothMesh/ios/MeshNode/Mesh/MeshConstants.swift new file mode 100644 index 00000000..ebcc70eb --- /dev/null +++ b/AryaaBluetoothMesh/ios/MeshNode/Mesh/MeshConstants.swift @@ -0,0 +1,11 @@ +import CoreBluetooth +import Foundation + +enum MeshConstants { + static let serviceUUID = CBUUID(string: "A6B5C4D3-E2F1-0987-6543-210FEDCBA987") + static let inboxUUID = CBUUID(string: "A6B5C4D3-E2F1-0987-6543-210FEDCBA988") + + static let defaultTTL: UInt8 = 5 + static let cacheSize = 256 + static let maxOutbound = 6 +} diff --git a/AryaaBluetoothMesh/ios/MeshNode/Mesh/MeshManager.swift b/AryaaBluetoothMesh/ios/MeshNode/Mesh/MeshManager.swift new file mode 100644 index 00000000..898f2c75 --- /dev/null +++ b/AryaaBluetoothMesh/ios/MeshNode/Mesh/MeshManager.swift @@ -0,0 +1,88 @@ +import Combine +import CoreBluetooth +import Foundation + +/// Orchestrates the mesh: owns Peripheral + Central, routes inbound writes +/// through the dedup cache, and forwards to connected peers (excluding source) +/// while TTL > 1. +final class MeshManager: NSObject, ObservableObject { + @Published private(set) var messages: [MeshMessage] = [] + @Published private(set) var connectedPeerCount: Int = 0 + @Published private(set) var sentCount: Int = 0 + @Published private(set) var receivedCount: Int = 0 + @Published private(set) var forwardedCount: Int = 0 + @Published private(set) var dedupedCount: Int = 0 + + let selfId: UUID + private var msgCounter: UInt32 = 0 + private let cache = MeshCache() + private let peripheral = MeshPeripheral() + private let central = MeshCentral() + + override init() { + if let stored = UserDefaults.standard.string(forKey: "meshSelfId"), + let uuid = UUID(uuidString: stored) { + self.selfId = uuid + } else { + let newId = UUID() + UserDefaults.standard.set(newId.uuidString, forKey: "meshSelfId") + self.selfId = newId + } + super.init() + peripheral.delegate = self + central.delegate = self + } + + func start() { + peripheral.start() + central.start() + } + + func send(text: String) { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + msgCounter += 1 + let msg = MeshMessage( + senderId: selfId, + msgId: msgCounter, + ttl: MeshConstants.defaultTTL, + timestamp: MeshMessage.nowMs(), + payload: trimmed + ) + cache.insert(sender: msg.senderId, msgId: msg.msgId) + messages.append(msg) + sentCount += 1 + guard let data = msg.encode() else { return } + central.broadcast(data, excluding: nil) + } + + private func handleIncoming(_ data: Data, from sourceId: String?) { + guard let msg = MeshMessage.decode(data) else { return } + if msg.senderId == selfId { return } + let isNew = cache.insert(sender: msg.senderId, msgId: msg.msgId) + guard isNew else { + dedupedCount += 1 + return + } + messages.append(msg) + receivedCount += 1 + guard msg.ttl > 1 else { return } + var forwarded = msg + forwarded.ttl -= 1 + guard let fwData = forwarded.encode() else { return } + central.broadcast(fwData, excluding: sourceId) + forwardedCount += 1 + } +} + +extension MeshManager: MeshPeripheralDelegate { + func peripheral(didReceive data: Data, from centralId: String) { + handleIncoming(data, from: centralId) + } +} + +extension MeshManager: MeshCentralDelegate { + func central(connectedPeersChanged count: Int) { + connectedPeerCount = count + } +} diff --git a/AryaaBluetoothMesh/ios/MeshNode/Mesh/MeshMessage.swift b/AryaaBluetoothMesh/ios/MeshNode/Mesh/MeshMessage.swift new file mode 100644 index 00000000..ea258ab4 --- /dev/null +++ b/AryaaBluetoothMesh/ios/MeshNode/Mesh/MeshMessage.swift @@ -0,0 +1,23 @@ +import Foundation + +struct MeshMessage: Codable, Identifiable, Equatable { + let senderId: UUID + let msgId: UInt32 + var ttl: UInt8 + let timestamp: Int64 + let payload: String + + var id: String { "\(senderId.uuidString):\(msgId)" } + + static func nowMs() -> Int64 { + Int64(Date().timeIntervalSince1970 * 1000) + } + + func encode() -> Data? { + try? JSONEncoder().encode(self) + } + + static func decode(_ data: Data) -> MeshMessage? { + try? JSONDecoder().decode(MeshMessage.self, from: data) + } +} diff --git a/AryaaBluetoothMesh/ios/MeshNode/Mesh/MeshPeripheral.swift b/AryaaBluetoothMesh/ios/MeshNode/Mesh/MeshPeripheral.swift new file mode 100644 index 00000000..44a33cd1 --- /dev/null +++ b/AryaaBluetoothMesh/ios/MeshNode/Mesh/MeshPeripheral.swift @@ -0,0 +1,70 @@ +import CoreBluetooth +import Foundation + +protocol MeshPeripheralDelegate: AnyObject { + func peripheral(didReceive data: Data, from centralId: String) +} + +/// Runs the Peripheral role: advertises the mesh service UUID and hosts +/// one writable "inbox" characteristic. Incoming writes are forwarded to +/// the delegate (MeshManager). +final class MeshPeripheral: NSObject { + weak var delegate: MeshPeripheralDelegate? + + private var manager: CBPeripheralManager! + private var inbox: CBMutableCharacteristic! + private var advertising = false + + func start() { + manager = CBPeripheralManager(delegate: self, queue: nil) + } + + private func setupService() { + inbox = CBMutableCharacteristic( + type: MeshConstants.inboxUUID, + properties: [.write, .writeWithoutResponse], + value: nil, + permissions: [.writeable] + ) + let service = CBMutableService(type: MeshConstants.serviceUUID, primary: true) + service.characteristics = [inbox] + manager.add(service) + } + + private func startAdvertising() { + guard !advertising else { return } + advertising = true + manager.startAdvertising([ + CBAdvertisementDataServiceUUIDsKey: [MeshConstants.serviceUUID], + CBAdvertisementDataLocalNameKey: "MeshNode" + ]) + } +} + +extension MeshPeripheral: CBPeripheralManagerDelegate { + func peripheralManagerDidUpdateState(_ peripheral: CBPeripheralManager) { + if peripheral.state == .poweredOn { + setupService() + } else { + advertising = false + } + } + + func peripheralManager(_ peripheral: CBPeripheralManager, + didAdd service: CBService, + error: Error?) { + if error == nil { + startAdvertising() + } + } + + func peripheralManager(_ peripheral: CBPeripheralManager, + didReceiveWrite requests: [CBATTRequest]) { + for req in requests { + guard req.characteristic.uuid == MeshConstants.inboxUUID, + let data = req.value else { continue } + delegate?.peripheral(didReceive: data, from: req.central.identifier.uuidString) + peripheral.respond(to: req, withResult: .success) + } + } +} diff --git a/AryaaBluetoothMesh/ios/MeshNode/MeshNodeApp.swift b/AryaaBluetoothMesh/ios/MeshNode/MeshNodeApp.swift new file mode 100644 index 00000000..b9be9e40 --- /dev/null +++ b/AryaaBluetoothMesh/ios/MeshNode/MeshNodeApp.swift @@ -0,0 +1,14 @@ +import SwiftUI + +@main +struct MeshNodeApp: App { + @StateObject private var mesh = MeshManager() + + var body: some Scene { + WindowGroup { + ContentView() + .environmentObject(mesh) + .onAppear { mesh.start() } + } + } +} diff --git a/AryaaBluetoothMesh/ios/project.yml b/AryaaBluetoothMesh/ios/project.yml new file mode 100644 index 00000000..a3521900 --- /dev/null +++ b/AryaaBluetoothMesh/ios/project.yml @@ -0,0 +1,41 @@ +name: MeshNode +options: + bundleIdPrefix: com.cactushack + deploymentTarget: + iOS: "17.0" + createIntermediateGroups: true + generateEmptyDirectories: true +targets: + MeshNode: + type: application + platform: iOS + sources: + - path: MeshNode + info: + path: MeshNode/Info.plist + properties: + CFBundleDisplayName: MeshNode + CFBundleShortVersionString: "0.1" + CFBundleVersion: "1" + LSRequiresIPhoneOS: true + NSBluetoothAlwaysUsageDescription: "MeshNode uses Bluetooth to communicate with nearby devices in the mesh network." + NSBluetoothPeripheralUsageDescription: "MeshNode uses Bluetooth to communicate with nearby devices in the mesh network." + UIApplicationSceneManifest: + UIApplicationSupportsMultipleScenes: false + UILaunchScreen: + UIColorName: "" + UISupportedInterfaceOrientations: + - UIInterfaceOrientationPortrait + UIRequiredDeviceCapabilities: + - armv7 + - bluetooth-le + settings: + base: + TARGETED_DEVICE_FAMILY: "1" + SWIFT_VERSION: "5.9" + PRODUCT_BUNDLE_IDENTIFIER: com.cactushack.MeshNode + IPHONEOS_DEPLOYMENT_TARGET: "17.0" + GENERATE_INFOPLIST_FILE: NO + MARKETING_VERSION: "0.1" + CURRENT_PROJECT_VERSION: "1" + CODE_SIGN_STYLE: Automatic diff --git a/DECISIONS.md b/DECISIONS.md new file mode 100644 index 00000000..6a9313bf --- /dev/null +++ b/DECISIONS.md @@ -0,0 +1,281 @@ +# TacNet — Design Decisions Log + +All decisions captured from the spec review session. + +--- + +## Decision 1: iOS Target + +**Question:** What's the minimum iOS version you want to support? + +**Choice:** `iOS 16+` (good balance) + +**Rationale:** Provides access to modern Swift Concurrency, SwiftData, and stable BLE APIs without excluding too many devices. + +--- + +## Decision 2: Cactus SDK Status + +**Question:** Will the Cactus SDK be available as a real iOS library during the hackathon, or are you designing against a mock/spec? + +**Choice:** `Real SDK available` + +**Rationale:** Full integration expected — not a mock. All code references in the spec should reflect expected Cactus SDK APIs. + +--- + +## Decision 3: BLE Audio Strategy + +**Question:** How should audio data be transmitted over BLE given the ~20-byte characteristic limit? + +**Choice:** `Audio stays local, only transcripts/summaries go over BLE` + +**Rationale:** This is the key architectural win. By keeping audio playback local and only transmitting text (transcripts outbound, summaries inbound), the BLE mesh avoids all throughput constraints. No chunking, no sequencing, no Data Mode needed. Each phone plays received audio locally from the sender's transmission. + +**Implication:** BROADCAST messages carry `transcript` (sender's STT output), not `audio_b64`. The receiving sibling/parent plays audio from the sender's local device (range permitting), while the transcript is what propagates up the compaction chain. + +--- + +## Decision 4: STT Fallback + +**Question:** If on-device STT via Cactus fails or is unavailable, which fallback do you prefer? + +**Choice:** `Local-only processing. No internet, no fallbacks. All STT via on-device Cactus/SLM` + +**Rationale:** Fully offline-first requirement. The system must work with zero connectivity. Gemma 4 via Cactus handles voice-to-text entirely on-device. + +**Implication:** No Apple Speech framework fallback. STT quality depends entirely on Cactus/Gemma's voice capabilities. + +--- + +## Decision 5: Compaction Latency + +**Question:** What's your target latency from last child message to summary emission? + +**Choice:** `1-2s (acceptable for tactical use)` + +**Rationale:** Prioritizes accuracy and coherent summarization over raw speed. Real-time feel is not critical for the compaction layer — it's a summarization tool, not a live radio replacement. + +--- + +## Decision 6: Tree Editor UI + +**Question:** How detailed should the tree builder interface be? + +**Choice:** `Full drag-and-drop reparenting and reordering` + +**Rationale:** The organiser needs full visual control over the hierarchy during setup and mid-operation. Drag-and-drop is the most intuitive way to reparent nodes and restructure the tree. + +**Implication:** `TreeBuilderView` uses SwiftUI drag gesture recognizers on `TreeNodeView` cells. Tree state is stored as a nested `TreeNode` model with `parent_id` references. + +--- + +## Decision 7: Message Persistence + +**Question:** Should messages be stored locally for after-action review? + +**Choice:** `Yes — full history with search` + +**Rationale:** Tactical operations require after-action review capability. All messages (BROADCASTs and COMPACTIONs) should be persisted locally with full-text search. + +**Implication:** SwiftData used for message history. Search index on `transcript`, `summary`, and `sender_role`. Ring buffer optional for storage limits, but full history accessible. + +--- + +## Decision 8: Role Transfer + +**Question:** Can the organiser transfer ownership/control of the network to another participant mid-operation? + +**Choice:** `Yes — organiser can promote any claimed node to organiser` + +**Rationale:** Real-world scenarios require handover. If the commander goes down, someone else needs to take control and restructure the tree. + +**Implication:** New message type `PROMOTE` — organiser broadcasts it with `target_node_id`. The target device receives organiser-level permissions. `NetworkConfig.created_by` updates. Old organiser becomes a regular participant. + +--- + +## Decision 9: Conflict Resolution + +**Question:** When two devices claim the same node simultaneously (race condition), how should it be resolved? + +**Choice:** `Organiser device wins automatically` + +**Rationale:** The organiser is the authority on tree state. If a conflict occurs, the organiser's claim takes precedence. This is simpler and more robust than timestamp-based resolution. + +**Implication:** On simultaneous `CLAIM` received, organiser's claim is accepted. Loser receives a `CLAIM_REJECTED` message with reason: `organiser_wins`. Loser device returns to role selection. + +--- + +## Decision 10: Dynamic Reparenting + +**Question:** If a parent node goes offline, should its children automatically reparent to the nearest available ancestor? + +**Choice:** `Yes — automatic reparenting` + +**Rationale:** Network resilience is critical. If a squad leader goes down, their squad members shouldn't be stranded. Automatic reparenting keeps the tree functional even as nodes drop. + +**Implication:** `TreeSyncService` monitors BLE connection state. On parent disconnect (60s timeout), children traverse upward to find nearest connected ancestor. `TREE_UPDATE` with new `parent_id` broadcast to all nodes. Routing rules update automatically. + +--- + +## Decision 11: Encryption + +**Question:** Do you want end-to-end encryption on BLE messages? + +**Choice:** `Yes — pre-shared key on network join` + +**Rationale:** Tactical communications are sensitive. All messages (BROADCAST, COMPACTION, CLAIM, TREE_UPDATE) should be encrypted end-to-end over the BLE mesh. + +**Implication:** Key exchange happens after PIN verification during network join. Organiser generates a session key, encrypts it with the pre-shared network key (or PIN-derived key), and sends it to the joining participant. All subsequent messages use AES-256 or similar symmetric encryption. + +--- + +## Decision 12: Location/GPS + +**Question:** Should GPS coordinates be embedded in messages for a map view? + +**Choice:** `Yes — embedded automatically` + +**Rationale:** Situational awareness is enhanced by knowing where each node is. GPS data enables a shared map view showing all active nodes. + +**Implication:** Every message envelope gets `location: { lat, lon, accuracy }` added automatically from Core Location. The root commander's screen can display a map with node positions. Map view is a potential 5th tab in the UI. + +--- + +## Decision 13: Audio Transport Clarification + +**Question:** The spec mentions audio plays on siblings/parent via "direct BLE audio profile" but Section 18 says "audio stays local on device; only transcripts cross the mesh." Which is the actual intent? + +**Choice:** `Text-only over BLE, no audio streaming` + +**Rationale:** Audio is NEVER transmitted over BLE. Only transcript text is sent over the mesh. Receiving devices display the text transcript. No BLE audio profile is used. + +**Implication:** Removes all BLE audio streaming complexity. BROADCAST messages carry transcript text only. Siblings and parents see text in their live feed, not audio playback. + +--- + +## Decision 14: STT Engine + +**Question:** Should Apple's built-in Speech framework handle STT (with Gemma 4 for summarization only), or should Cactus/Gemma 4 handle both? + +**Choice:** `Cactus/Gemma 4 E4B for both STT and summarization` + +**Rationale:** Gemma 4 E4B has a native ~300M param audio conformer encoder — it handles audio input natively, not as a bolt-on. No separate STT model needed. Single model for both transcription and compaction. + +**Implication:** Two-step pipeline using one model: (1) `cactusTranscribe` or `cactusComplete` with PCM audio to get transcript, (2) `cactusComplete` with collected transcripts to produce compacted summary. No Apple Speech framework, no Whisper. + +--- + +## Decision 15: Model Tier Strategy + +**Question:** Use different model sizes for different node roles (E2B for leaf, E4B for intermediate/root), or a single model for all? + +**Choice:** `E4B on all devices for MVP simplicity` + +**Rationale:** Hackathon MVP — one model simplifies deployment, testing, and debugging. E4B (4.5B params, ~2.8GB VRAM) fits on iPhone 15/16 with 8GB RAM. Can differentiate later if needed. + +**Implication:** Single model download, single model init path, uniform performance expectations across all nodes. + +--- + +## Decision 16: Audio Pipeline Architecture + +**Question:** Should push-to-talk send raw audio directly to `cactusComplete` (one-pass audio-to-summary), or do a separate transcription step first? + +**Choice:** `Two-step: transcribe audio first (show transcript in feed), then compact separately` + +**Rationale:** The intermediate transcript is needed for the live feed display — siblings and parents see the text of what was said. Compaction is a separate step that aggregates multiple transcripts at the parent level. + +**Implication:** Leaf nodes: record audio -> `cactusTranscribe` -> display + broadcast transcript. Parent nodes: collect child transcripts -> `cactusComplete` with summarization prompt -> broadcast compaction upward. + +--- + +## Decision 17: Model Weight Delivery + +**Question:** Bundle model weights (6.7GB) in the app binary, or download on first launch? + +**Choice:** `Download on first launch` + +**Rationale:** 6.7GB bundled in the IPA is impractical. First-launch download allows a smaller app binary. Requires WiFi and a download progress UI, but this is a one-time setup. + +**Implication:** Need a model download manager with progress UI, storage check, and a "download complete" gate before the app becomes functional. + +--- + +## Decision 18: Cactus SDK Integration Method + +**Question:** How is the Cactus SDK integrated into the iOS project? + +**Choice:** `Pre-built XCFramework from source` + +**Rationale:** Cactus does not provide SPM or CocoaPods. The SDK is built from source via `apple/build.sh` (86 seconds), producing `cactus-ios.xcframework`. The Swift API is a single file (`Cactus.swift`) wrapping the C FFI. + +**Details:** +- XCFramework: `cactus/apple/cactus-ios.xcframework/` +- Swift API: `cactus/apple/Cactus.swift` (free functions, not classes) +- Model weights: `/opt/homebrew/opt/cactus/libexec/weights/gemma-4-e4b-it/` (6.7GB INT4) +- Key functions: `cactusInit`, `cactusComplete`, `cactusTranscribe`, `cactusStreamTranscribeStart/Process/Stop`, `cactusDestroy` +- Audio format: 16-bit PCM, 16kHz mono + +--- + +## Decision 19: Scope + +**Question:** Build full spec or MVP first? + +**Choice:** `Full spec — no cuts` + +**Rationale:** 3+ days of build time, 4+ test iPhones, unlimited resources. All features from the Orchestrator.md will be built: encryption, auto-reparenting, SwiftData persistence, drag-and-drop tree editor, organiser promote, Data Flow screen, etc. + +--- + +## Decision 20: Testing Strategy + +**Question:** How will the app be tested during development? + +**Choice:** `XCTest for logic + manual device testing for BLE/AI` + +**Rationale:** XCTest unit tests cover all pure logic (models, routing, compaction, tree sync, message dedup). BLE mesh and on-device AI can only be validated on physical devices. 4+ iPhones available for full demo testing. + +--- + +## Decision 21: Milestones + +**Question:** How should the build be structured into vertical slices? + +**Choice:** `5 milestones` + +**Breakdown:** +1. **Foundation** — Xcode project setup, data models, Cactus SDK integration, BLE mesh core +2. **Tree & Roles** — tree builder UI, network discovery, role claiming protocol +3. **Comms Core** — push-to-talk, transcription, broadcast routing, compaction engine +4. **Full UX** — all 4 screens polished, SwiftData persistence, drag-and-drop tree editor +5. **Resilience** — encryption, auto-reparenting, dynamic tree editing, model download on first launch + +--- + +## Summary Table + +| # | Decision | Choice | +|---|---|---| +| 1 | iOS Target | iOS 16+ | +| 2 | Cactus SDK | Real SDK | +| 3 | Audio over BLE | Local only — transcripts/summaries only | +| 4 | STT Fallback | None — local-only | +| 5 | Compaction Latency | 1-2s | +| 6 | Tree Editor UI | Full drag-and-drop | +| 7 | Message Persistence | Full history + search | +| 8 | Role Transfer | Organiser can promote any node | +| 9 | Conflict Resolution | Organiser wins automatically | +| 10 | Dynamic Reparenting | Automatic | +| 11 | Encryption | E2E with pre-shared key | +| 12 | GPS/Location | Auto-embedded in all messages | +| 13 | Audio Transport | Text-only over BLE, no audio streaming | +| 14 | STT Engine | Cactus/Gemma 4 E4B for both STT and summarization | +| 15 | Model Tiers | E4B on all devices (MVP simplicity) | +| 16 | Audio Pipeline | Two-step: transcribe first, then compact separately | +| 17 | Model Delivery | Download on first launch | +| 18 | SDK Integration | Pre-built XCFramework + Cactus.swift | +| 19 | Scope | Full spec, no cuts | +| 20 | Testing Strategy | XCTest + manual device testing | +| 21 | Milestones | 5 vertical slices | diff --git a/Frameworks/cactus-ios.xcframework/Info.plist b/Frameworks/cactus-ios.xcframework/Info.plist new file mode 100644 index 00000000..55424d63 --- /dev/null +++ b/Frameworks/cactus-ios.xcframework/Info.plist @@ -0,0 +1,39 @@ + + + + + AvailableLibraries + + + LibraryIdentifier + ios-arm64 + LibraryPath + cactus.framework + SupportedArchitectures + + arm64 + + SupportedPlatform + ios + + + LibraryIdentifier + ios-arm64-simulator + LibraryPath + cactus.framework + SupportedArchitectures + + arm64 + + SupportedPlatform + ios + SupportedPlatformVariant + simulator + + + CFBundlePackageType + XFWK + XCFrameworkFormatVersion + 1.0 + + diff --git a/Frameworks/cactus-ios.xcframework/ios-arm64-simulator/cactus.framework/Headers/cactus.h b/Frameworks/cactus-ios.xcframework/ios-arm64-simulator/cactus.framework/Headers/cactus.h new file mode 100644 index 00000000..b5b6d803 --- /dev/null +++ b/Frameworks/cactus-ios.xcframework/ios-arm64-simulator/cactus.framework/Headers/cactus.h @@ -0,0 +1,12 @@ +#ifndef CACTUS_H +#define CACTUS_H + +#include "graph/graph.h" +#include "kernel/kernel.h" +#include "kernel/kernel_utils.h" +#include "engine/engine.h" +#include "models/model.h" +#include "ffi/cactus_ffi.h" +#include "npu/npu.h" + +#endif // CACTUS_H \ No newline at end of file diff --git a/Frameworks/cactus-ios.xcframework/ios-arm64-simulator/cactus.framework/Headers/cactus_cloud.h b/Frameworks/cactus-ios.xcframework/ios-arm64-simulator/cactus.framework/Headers/cactus_cloud.h new file mode 100644 index 00000000..e61841d7 --- /dev/null +++ b/Frameworks/cactus-ios.xcframework/ios-arm64-simulator/cactus.framework/Headers/cactus_cloud.h @@ -0,0 +1,48 @@ +#ifndef CACTUS_CLOUD_H +#define CACTUS_CLOUD_H + +#include "cactus_utils.h" +#include +#include + +namespace cactus { +namespace ffi { + +struct CloudResponse { + std::string transcript; + std::string api_key_hash; + bool used_cloud = false; + std::string error; +}; + +struct CloudCompletionRequest { + std::vector messages; + std::vector tools; + std::string local_output; + std::vector local_function_calls; + bool has_images = false; + std::string cloud_key; +}; + +struct CloudCompletionResult { + bool ok = false; + bool used_cloud = false; + std::string response; + std::vector function_calls; + std::string error; +}; + +std::string cloud_base64_encode(const uint8_t* data, size_t len); +std::vector cloud_build_wav(const uint8_t* pcm, size_t pcm_bytes); +std::string resolve_cloud_api_key(const char* cloud_key_param); +CloudResponse cloud_transcribe_request(const std::string& audio_b64, + const std::string& fallback_text, + long timeout_seconds = 15L, + const char* cloud_key = nullptr); +CloudCompletionResult cloud_complete_request(const CloudCompletionRequest& request, + long timeout_ms); + +} // namespace ffi +} // namespace cactus + +#endif // CACTUS_CLOUD_H diff --git a/Frameworks/cactus-ios.xcframework/ios-arm64-simulator/cactus.framework/Headers/cactus_ffi.h b/Frameworks/cactus-ios.xcframework/ios-arm64-simulator/cactus.framework/Headers/cactus_ffi.h new file mode 100644 index 00000000..163e1805 --- /dev/null +++ b/Frameworks/cactus-ios.xcframework/ios-arm64-simulator/cactus.framework/Headers/cactus_ffi.h @@ -0,0 +1,454 @@ +#ifndef CACTUS_FFI_H +#define CACTUS_FFI_H + +#include +#include +#include + +#if __GNUC__ >= 4 + #define CACTUS_FFI_EXPORT __attribute__((visibility("default"))) + #define CACTUS_FFI_LOCAL __attribute__((visibility("hidden"))) +#else + #define CACTUS_FFI_EXPORT + #define CACTUS_FFI_LOCAL +#endif + +#ifdef __cplusplus +extern "C" { +#endif + +typedef void* cactus_model_t; +typedef void* cactus_index_t; +typedef void* cactus_stream_transcribe_t; + +typedef void (*cactus_token_callback)(const char* token, uint32_t token_id, void* user_data); + +CACTUS_FFI_EXPORT cactus_model_t cactus_init( + const char* model_path, + const char* corpus_dir, // optional: NULL if no RAG corpus + bool cache_index // false = always rebuild index, true = load cached if available +); + +CACTUS_FFI_EXPORT void cactus_destroy(cactus_model_t model); +CACTUS_FFI_EXPORT void cactus_reset(cactus_model_t model); +CACTUS_FFI_EXPORT void cactus_stop(cactus_model_t model); + +CACTUS_FFI_EXPORT int cactus_complete( + cactus_model_t model, + const char* messages_json, + char* response_buffer, + size_t buffer_size, + const char* options_json, // optional + const char* tools_json, // optional + cactus_token_callback callback, // optional + void* user_data, // optional + const uint8_t* pcm_buffer, // optional: NULL when not used + size_t pcm_buffer_size // optional: 0 when not used +); + +CACTUS_FFI_EXPORT int cactus_prefill( + cactus_model_t model, + const char* messages_json, + char* response_buffer, + size_t buffer_size, + const char* options_json, // optional + const char* tools_json, // optional + const uint8_t* pcm_buffer, // optional: NULL when not used + size_t pcm_buffer_size // optional: 0 when not used +); + +CACTUS_FFI_EXPORT int cactus_tokenize( + cactus_model_t model, + const char* text, + uint32_t* token_buffer, + size_t token_buffer_len, + size_t* out_token_len +); + +CACTUS_FFI_EXPORT int cactus_score_window( + cactus_model_t model, + const uint32_t* tokens, + size_t token_len, + size_t start, + size_t end, + size_t context, + char* response_buffer, + size_t buffer_size +); + +CACTUS_FFI_EXPORT int cactus_transcribe( + cactus_model_t model, + const char* audio_file_path, // NULL if using pcm_buffer + const char* prompt, + char* response_buffer, + size_t buffer_size, + const char* options_json, // optional + cactus_token_callback callback, // optional + void* user_data, // optional + const uint8_t* pcm_buffer, // NULL if using audio_file_path + size_t pcm_buffer_size +); + +CACTUS_FFI_EXPORT int cactus_detect_language( + cactus_model_t model, + const char* audio_file_path, // NULL if using pcm_buffer + char* response_buffer, + size_t buffer_size, + const char* options_json, // optional + const uint8_t* pcm_buffer, // NULL if using audio_file_path + size_t pcm_buffer_size +); + +CACTUS_FFI_EXPORT cactus_stream_transcribe_t cactus_stream_transcribe_start( + cactus_model_t model, + const char* options_json // optional +); + +CACTUS_FFI_EXPORT int cactus_stream_transcribe_process( + cactus_stream_transcribe_t stream, + const uint8_t* pcm_buffer, + size_t pcm_buffer_size, + char* response_buffer, + size_t buffer_size +); + +CACTUS_FFI_EXPORT int cactus_stream_transcribe_stop( + cactus_stream_transcribe_t stream, + char* response_buffer, + size_t buffer_size +); + +CACTUS_FFI_EXPORT int cactus_embed( + cactus_model_t model, + const char* text, + float* embeddings_buffer, + size_t buffer_size, + size_t* embedding_dim, + bool normalize +); + +CACTUS_FFI_EXPORT int cactus_image_embed( + cactus_model_t model, + const char* image_path, + float* embeddings_buffer, + size_t buffer_size, + size_t* embedding_dim +); + +CACTUS_FFI_EXPORT int cactus_audio_embed( + cactus_model_t model, + const char* audio_path, + float* embeddings_buffer, + size_t buffer_size, + size_t* embedding_dim +); + +CACTUS_FFI_EXPORT int cactus_vad( + cactus_model_t model, + const char* audio_file_path, + char* response_buffer, + size_t buffer_size, + const char* options_json, + const uint8_t* pcm_buffer, + size_t pcm_buffer_size +); + +CACTUS_FFI_EXPORT int cactus_diarize( + cactus_model_t model, + const char* audio_file_path, + char* response_buffer, + size_t buffer_size, + const char* options_json, + const uint8_t* pcm_buffer, + size_t pcm_buffer_size +); + +CACTUS_FFI_EXPORT int cactus_embed_speaker( + cactus_model_t model, + const char* audio_file_path, + char* response_buffer, + size_t buffer_size, + const char* options_json, + const uint8_t* pcm_buffer, + size_t pcm_buffer_size, + const float* mask_weights, + size_t mask_num_frames +); + +CACTUS_FFI_EXPORT int cactus_rag_query( + cactus_model_t model, + const char* query, + char* response_buffer, + size_t buffer_size, + size_t top_k +); + +CACTUS_FFI_EXPORT cactus_index_t cactus_index_init( + const char* index_dir, + size_t embedding_dim +); + +CACTUS_FFI_EXPORT int cactus_index_add( + cactus_index_t index, + const int* ids, + const char** documents, + const char** metadatas, // optional: can be NULL + const float** embeddings, + size_t count, + size_t embedding_dim +); + +CACTUS_FFI_EXPORT int cactus_index_delete( + cactus_index_t index, + const int* ids, + size_t ids_count +); + +CACTUS_FFI_EXPORT int cactus_index_get( + cactus_index_t index, + const int* ids, + size_t ids_count, + char** document_buffers, + size_t* document_buffer_sizes, + char** metadata_buffers, + size_t* metadata_buffer_sizes, + float** embedding_buffers, + size_t* embedding_buffer_sizes +); + +CACTUS_FFI_EXPORT int cactus_index_query( + cactus_index_t index, + const float** embeddings, + size_t embeddings_count, + size_t embedding_dim, + const char* options_json, // optional + int** id_buffers, + size_t* id_buffer_sizes, + float** score_buffers, + size_t* score_buffer_sizes +); + +CACTUS_FFI_EXPORT int cactus_index_compact(cactus_index_t index); +CACTUS_FFI_EXPORT void cactus_index_destroy(cactus_index_t index); + +CACTUS_FFI_EXPORT const char* cactus_get_last_error(void); + +// level: 0=DEBUG, 1=INFO, 2=WARN (default), 3=ERROR, 4=NONE +CACTUS_FFI_EXPORT void cactus_log_set_level(int level); + +typedef void (*cactus_log_callback_t)(int level, const char* component, const char* message, void* user_data); +CACTUS_FFI_EXPORT void cactus_log_set_callback(cactus_log_callback_t callback, void* user_data); + +CACTUS_FFI_EXPORT void cactus_set_telemetry_environment(const char* framework, const char* cache_location, const char* version); +CACTUS_FFI_EXPORT void cactus_set_app_id(const char* app_id); +CACTUS_FFI_EXPORT void cactus_telemetry_flush(void); +CACTUS_FFI_EXPORT void cactus_telemetry_shutdown(void); + +// cactus graph export +typedef void* cactus_graph_t; +typedef uint64_t cactus_node_t; + +typedef struct { + int32_t precision; + size_t rank; + size_t shape[8]; + size_t num_elements; + size_t byte_size; +} cactus_tensor_info_t; + +CACTUS_FFI_EXPORT cactus_graph_t cactus_graph_create(void); +CACTUS_FFI_EXPORT void cactus_graph_destroy(cactus_graph_t graph); +CACTUS_FFI_EXPORT int cactus_graph_hard_reset(cactus_graph_t graph); + +CACTUS_FFI_EXPORT int cactus_graph_save(cactus_graph_t graph, const char* filename); +CACTUS_FFI_EXPORT cactus_graph_t cactus_graph_load(const char* filename); + +CACTUS_FFI_EXPORT int cactus_graph_input( + cactus_graph_t graph, const size_t* shape, size_t rank, int32_t precision, +cactus_node_t* out_node); + +CACTUS_FFI_EXPORT int cactus_graph_set_input( + cactus_graph_t graph, cactus_node_t node, const void* data, int32_t +precision); +CACTUS_FFI_EXPORT int cactus_graph_set_external_input( + cactus_graph_t graph, cactus_node_t node, void* data, int32_t precision); + +CACTUS_FFI_EXPORT int cactus_graph_precision_cast( + cactus_graph_t graph, cactus_node_t input, int32_t target_precision, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_quantize_activations( + cactus_graph_t graph, cactus_node_t input, cactus_node_t* out); + +CACTUS_FFI_EXPORT int cactus_graph_add(cactus_graph_t graph, cactus_node_t a, +cactus_node_t b, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_add_clipped(cactus_graph_t graph, cactus_node_t a, +cactus_node_t b, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_subtract(cactus_graph_t graph, cactus_node_t +a, cactus_node_t b, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_multiply(cactus_graph_t graph, cactus_node_t +a, cactus_node_t b, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_divide(cactus_graph_t graph, cactus_node_t +a, cactus_node_t b, cactus_node_t* out); + +CACTUS_FFI_EXPORT int cactus_graph_scalar_add(cactus_graph_t graph, cactus_node_t x, float value, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_scalar_subtract(cactus_graph_t graph, cactus_node_t x, float value, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_scalar_multiply(cactus_graph_t graph, cactus_node_t x, float value, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_scalar_divide(cactus_graph_t graph, cactus_node_t x, float value, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_scalar_exp(cactus_graph_t graph, cactus_node_t x, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_scalar_sqrt(cactus_graph_t graph, cactus_node_t x, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_scalar_cos(cactus_graph_t graph, cactus_node_t x, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_scalar_sin(cactus_graph_t graph, cactus_node_t x, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_scalar_log(cactus_graph_t graph, cactus_node_t x, cactus_node_t* out); + +CACTUS_FFI_EXPORT int cactus_graph_abs(cactus_graph_t graph, cactus_node_t x, +cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_pow(cactus_graph_t graph, cactus_node_t x, +float exponent, cactus_node_t* out); + +CACTUS_FFI_EXPORT int cactus_graph_view( + cactus_graph_t graph, cactus_node_t x, const size_t* shape, size_t rank, +cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_flatten( + cactus_graph_t graph, cactus_node_t x, int32_t start_dim, int32_t end_dim, +cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_reshape( + cactus_graph_t graph, cactus_node_t x, const size_t* shape, size_t rank, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_transpose( + cactus_graph_t graph, cactus_node_t x, int32_t backend, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_transpose_n( + cactus_graph_t graph, cactus_node_t x, const size_t* permutation, size_t rank, int32_t backend, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_slice( + cactus_graph_t graph, cactus_node_t x, int32_t axis, size_t start, size_t length, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_index( + cactus_graph_t graph, cactus_node_t x, size_t index_value, int32_t dim, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_sum(cactus_graph_t graph, cactus_node_t x, int32_t axis, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_mean(cactus_graph_t graph, cactus_node_t x, int32_t axis, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_variance(cactus_graph_t graph, cactus_node_t x, int32_t axis, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_min(cactus_graph_t graph, cactus_node_t x, int32_t axis, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_max(cactus_graph_t graph, cactus_node_t x, int32_t axis, cactus_node_t* out); + +CACTUS_FFI_EXPORT int cactus_graph_concat( + cactus_graph_t graph, cactus_node_t a, cactus_node_t b, int32_t axis, +cactus_node_t* out); + +CACTUS_FFI_EXPORT int cactus_graph_cat( + cactus_graph_t graph, const cactus_node_t* nodes, size_t count, int32_t +axis, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_matmul( + cactus_graph_t graph, cactus_node_t a, cactus_node_t b, bool pretransposed_rhs, int32_t backend, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_gather( + cactus_graph_t graph, cactus_node_t tensor, cactus_node_t indices, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_embedding_from_tensor( + cactus_graph_t graph, cactus_node_t embedding_tensor, cactus_node_t indices, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_embedding_from_file( + cactus_graph_t graph, const char* filename, cactus_node_t indices, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_mmap_embeddings( + cactus_graph_t graph, const char* filename, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_mmap_weights( + cactus_graph_t graph, const char* filename, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_bilinear_interpolation( + cactus_graph_t graph, cactus_node_t pos_embeds, size_t dst_height, size_t dst_width, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_set_grouped_scales( + cactus_graph_t graph, cactus_node_t node, size_t group_size, size_t num_groups, void* scales_ptr); +CACTUS_FFI_EXPORT int cactus_graph_set_interleaved( + cactus_graph_t graph, cactus_node_t node, bool interleaved, size_t original_n); +CACTUS_FFI_EXPORT int cactus_graph_release_weight_pages(cactus_graph_t graph, cactus_node_t node); +CACTUS_FFI_EXPORT int cactus_graph_prefetch_weight_pages(cactus_graph_t graph, cactus_node_t node); +CACTUS_FFI_EXPORT int cactus_graph_release_all_weight_pages(cactus_graph_t graph); + +CACTUS_FFI_EXPORT int cactus_graph_relu(cactus_graph_t graph, cactus_node_t x, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_silu(cactus_graph_t graph, cactus_node_t x, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_gelu(cactus_graph_t graph, cactus_node_t x, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_gelu_erf(cactus_graph_t graph, cactus_node_t x, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_sigmoid(cactus_graph_t graph, cactus_node_t x, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_tanh(cactus_graph_t graph, cactus_node_t x, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_glu(cactus_graph_t graph, cactus_node_t x, int32_t axis, cactus_node_t* out); + +CACTUS_FFI_EXPORT int cactus_graph_layernorm( + cactus_graph_t graph, cactus_node_t input, cactus_node_t weight, cactus_node_t bias, float epsilon, bool has_bias, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_groupnorm( + cactus_graph_t graph, cactus_node_t input, cactus_node_t weight, cactus_node_t bias, size_t num_groups, float epsilon, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_batchnorm( + cactus_graph_t graph, cactus_node_t input, cactus_node_t weight, cactus_node_t bias, cactus_node_t running_mean, cactus_node_t running_var, int32_t axis, float epsilon, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_topk(cactus_graph_t graph, cactus_node_t input, size_t k, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_rms_norm( + cactus_graph_t graph, cactus_node_t input, cactus_node_t weight, float epsilon, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_rope( + cactus_graph_t graph, cactus_node_t input, float theta, size_t position_offset, int32_t backend, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_rope_gptj( + cactus_graph_t graph, cactus_node_t input, float theta, size_t position_offset, size_t rot_dim, int32_t backend, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_softmax(cactus_graph_t graph, cactus_node_t input, int32_t axis, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_attention( + cactus_graph_t graph, cactus_node_t query, cactus_node_t key, cactus_node_t value, float scale, bool is_causal, size_t position_offset, size_t window_size, int32_t backend, bool use_mask, cactus_node_t mask, bool additive_mask, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_rel_pos_bias( + cactus_graph_t graph, cactus_node_t query, cactus_node_t relative_key, float scale, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_attention_int8_hybrid( + cactus_graph_t graph, cactus_node_t query, cactus_node_t key_new, cactus_node_t value_new, float scale, size_t position_offset, + const int8_t* cached_keys, const int8_t* cached_values, const float* k_scales, const float* v_scales, + size_t cache_len, size_t num_kv_heads, size_t head_dim, size_t window_size, cactus_node_t* out); + +CACTUS_FFI_EXPORT int cactus_graph_conv1d_causal( + cactus_graph_t graph, cactus_node_t input, cactus_node_t weight, size_t kernel_size, size_t dilation, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_conv1d_k3( + cactus_graph_t graph, cactus_node_t input, cactus_node_t weight, size_t stride, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_conv1d_k7s3( + cactus_graph_t graph, cactus_node_t input, cactus_node_t weight, cactus_node_t bias, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_conv1d( + cactus_graph_t graph, cactus_node_t input, cactus_node_t weight, bool has_bias, cactus_node_t bias, size_t stride, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_conv1d_same_depthwise_k9( + cactus_graph_t graph, cactus_node_t input, cactus_node_t weight, bool has_bias, cactus_node_t bias, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_conv1d_pointwise( + cactus_graph_t graph, cactus_node_t input, cactus_node_t weight, bool has_bias, cactus_node_t bias, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_conv2d_k3s2p1( + cactus_graph_t graph, cactus_node_t input, cactus_node_t weight, bool has_bias, cactus_node_t bias, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_conv2d_depthwise_k3s2p1( + cactus_graph_t graph, cactus_node_t input, cactus_node_t weight, bool has_bias, cactus_node_t bias, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_conv2d_pointwise_1x1( + cactus_graph_t graph, cactus_node_t input, cactus_node_t weight, bool has_bias, cactus_node_t bias, cactus_node_t* out); + +CACTUS_FFI_EXPORT int cactus_graph_lstm_cell( + cactus_graph_t graph, cactus_node_t input, cactus_node_t h_prev, cactus_node_t c_prev, cactus_node_t weight_ih, cactus_node_t weight_hh, cactus_node_t bias_ih, cactus_node_t bias_hh, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_gated_deltanet_decode( + cactus_graph_t graph, cactus_node_t query, cactus_node_t key, cactus_node_t value, cactus_node_t gate_log, cactus_node_t beta, cactus_node_t initial_state, float scale, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_gated_deltanet_prefill( + cactus_graph_t graph, cactus_node_t query, cactus_node_t key, cactus_node_t value, cactus_node_t gate_log, cactus_node_t beta, cactus_node_t initial_state, size_t chunk_size, float scale, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_stft( + cactus_graph_t graph, cactus_node_t input, cactus_node_t weight, size_t stride, size_t num_fft_bins, cactus_node_t* out); + +CACTUS_FFI_EXPORT int cactus_graph_altup_predict( + cactus_graph_t graph, cactus_node_t coefs, const cactus_node_t* streams, size_t num_streams, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_altup_correct( + cactus_graph_t graph, cactus_node_t coefs, cactus_node_t innovation, const cactus_node_t* predictions, size_t num_predictions, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_gaussian_topk( + cactus_graph_t graph, cactus_node_t input, float ppf, cactus_node_t* out); + +CACTUS_FFI_EXPORT int cactus_graph_moe_layer_gated( + cactus_graph_t graph, cactus_node_t hidden, cactus_node_t routing_probs, cactus_node_t topk_indices, + const cactus_node_t* w1_weights, const cactus_node_t* w3_weights, const cactus_node_t* w2_weights, + size_t num_experts, size_t num_experts_per_tok, bool normalize_routing, float epsilon, float routed_scaling_factor, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_moe_layer_ungated( + cactus_graph_t graph, cactus_node_t hidden, cactus_node_t routing_probs, cactus_node_t topk_indices, + const cactus_node_t* w1_weights, const cactus_node_t* w2_weights, + size_t num_experts, size_t num_experts_per_tok, bool normalize_routing, float epsilon, float routed_scaling_factor, int32_t activation, cactus_node_t* out); + +CACTUS_FFI_EXPORT int cactus_graph_sample( + cactus_graph_t graph, cactus_node_t logits, float temperature, float top_p, size_t top_k, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_scatter_topk( + cactus_graph_t graph, cactus_node_t indices, cactus_node_t values, size_t num_classes, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_persistent( + cactus_graph_t graph, cactus_node_t source_node, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_is_populated( + cactus_graph_t graph, cactus_node_t persistent_node, int32_t* out_is_populated); +CACTUS_FFI_EXPORT int cactus_graph_invalidate_persistent( + cactus_graph_t graph, cactus_node_t persistent_node); + +CACTUS_FFI_EXPORT int cactus_graph_execute(cactus_graph_t graph); +CACTUS_FFI_EXPORT int cactus_graph_get_output_ptr(cactus_graph_t graph, +cactus_node_t node, void** out_ptr); +CACTUS_FFI_EXPORT int cactus_graph_get_output_info(cactus_graph_t graph, +cactus_node_t node, cactus_tensor_info_t* out_info); + +#ifdef __cplusplus +} +#endif + +#endif // CACTUS_FFI_H diff --git a/Frameworks/cactus-ios.xcframework/ios-arm64-simulator/cactus.framework/Headers/cactus_utils.h b/Frameworks/cactus-ios.xcframework/ios-arm64-simulator/cactus.framework/Headers/cactus_utils.h new file mode 100644 index 00000000..bc2d4d58 --- /dev/null +++ b/Frameworks/cactus-ios.xcframework/ios-arm64-simulator/cactus.framework/Headers/cactus_utils.h @@ -0,0 +1,1850 @@ +#ifndef CACTUS_UTILS_H +#define CACTUS_UTILS_H + +#include "../engine/engine.h" +#include "../models/model.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef __APPLE__ +#include +#include +#elif defined(_WIN32) +#include +#include +#elif defined(__linux__) || defined(__ANDROID__) +#include +#endif + +inline size_t get_memory_footprint_bytes() { +#ifdef __APPLE__ + task_vm_info_data_t vm_info; + mach_msg_type_number_t count = TASK_VM_INFO_COUNT; + if (task_info(mach_task_self(), TASK_VM_INFO, (task_info_t)&vm_info, &count) == KERN_SUCCESS) + return vm_info.phys_footprint; + +#elif defined(_WIN32) + PROCESS_MEMORY_COUNTERS_EX pmc; + if (GetProcessMemoryInfo(GetCurrentProcess(), (PROCESS_MEMORY_COUNTERS*)&pmc, sizeof(pmc))) + return pmc.PrivateUsage; + +#elif defined(__linux__) || defined(__ANDROID__) + std::ifstream statm("/proc/self/statm"); + if (statm.is_open()) { + size_t size, resident; + statm >> size >> resident; + return resident * sysconf(_SC_PAGESIZE); + } +#endif + return 0; +} + +inline double get_ram_usage_mb() { + return get_memory_footprint_bytes() / (1024.0 * 1024.0); +} + +struct CactusModelHandle { + std::unique_ptr model; + std::unique_ptr vad_model; + std::atomic should_stop; + std::vector processed_tokens; + struct ProcessedImage { + std::string path; + long long last_modified_timestamp = 0; + + bool operator==(const ProcessedImage& other) const { + return path == other.path && last_modified_timestamp == other.last_modified_timestamp; + } + }; + + std::vector> processed_images; + std::mutex model_mutex; + std::string model_name; + std::unique_ptr corpus_index; + std::string corpus_dir; + size_t corpus_embedding_dim = 0; + std::vector> tool_embeddings; + std::vector tool_texts; + + CactusModelHandle() : should_stop(false) {} +}; + +extern std::string last_error_message; + +bool matches_stop_sequence(const std::vector& generated_tokens, + const std::vector>& stop_sequences); + +std::string retrieve_rag_context(CactusModelHandle* handle, const std::string& query); + +namespace cactus { +namespace audio { + +static constexpr size_t WHISPER_TARGET_FRAMES = 3000; +static constexpr int WHISPER_SAMPLE_RATE = 16000; + +inline cactus::engine::AudioProcessor::SpectrogramConfig get_whisper_spectrogram_config() { + cactus::engine::AudioProcessor::SpectrogramConfig cfg{}; + cfg.n_fft = 400; + cfg.frame_length = 400; + cfg.hop_length = 160; + cfg.power = 2.0f; + cfg.center = true; + cfg.pad_mode = "reflect"; + cfg.onesided = true; + cfg.dither = 0.0f; + cfg.mel_floor = 1e-10f; + cfg.log_mel = "log10"; + cfg.reference = 1.0f; + cfg.min_value = 1e-10f; + cfg.remove_dc_offset = true; + return cfg; +} + +inline cactus::engine::AudioProcessor::SpectrogramConfig get_parakeet_spectrogram_config() { + cactus::engine::AudioProcessor::SpectrogramConfig cfg{}; + cfg.n_fft = 512; + cfg.frame_length = 400; + cfg.hop_length = 160; + cfg.power = 2.0f; + cfg.center = true; + cfg.pad_mode = "constant"; + cfg.onesided = true; + cfg.dither = 0.0f; + cfg.mel_floor = 5.960464477539063e-08f; // 2^-24 guard value used by HF Parakeet. + cfg.log_mel = "log"; + cfg.reference = 1.0f; + cfg.min_value = 1e-10f; + cfg.remove_dc_offset = false; + cfg.hann_periodic = false; + return cfg; +} + +inline cactus::engine::AudioProcessor::SpectrogramConfig get_htk_spectrogram_config() { + cactus::engine::AudioProcessor::SpectrogramConfig cfg{}; + cfg.n_fft = 321; + cfg.frame_length = 320; + cfg.fft_override = 1024; + cfg.hop_length = 160; + cfg.power = 1.0f; + cfg.center = false; + cfg.pad_mode = "constant"; + cfg.onesided = true; + cfg.dither = 0.0f; + cfg.mel_floor = 0.001f; + cfg.log_mel = "log"; + cfg.reference = 1.0f; + cfg.min_value = 0.001f; + cfg.remove_dc_offset = false; + cfg.hann_periodic = true; + return cfg; +} + +inline cactus::engine::AudioProcessor::SpectrogramConfig get_gemma4_audio_spectrogram_config( + const cactus::engine::Config& model_config) { + auto cfg = get_htk_spectrogram_config(); + cfg.fft_override = model_config.audio_fft_length; + cfg.mel_floor_additive = true; + return cfg; +} + +inline cactus::engine::AudioProcessor::SpectrogramConfig get_wespeaker_spectrogram_config() { + cactus::engine::AudioProcessor::SpectrogramConfig cfg{}; + cfg.n_fft = 512; + cfg.frame_length = 400; + cfg.hop_length = 160; + cfg.power = 2.0f; + cfg.center = false; + cfg.pad_mode = "constant"; + cfg.onesided = true; + cfg.dither = 0.0f; + cfg.mel_floor = 1.1754944e-38f; + cfg.log_mel = "log"; + cfg.reference = 1.0f; + cfg.min_value = 1.1754944e-38f; + cfg.remove_dc_offset = true; + cfg.preemphasis = 0.97f; + cfg.hann_periodic = false; + cfg.window_a0 = 0.54f; + return cfg; +} + +// Whisper v1/v2: 80 mel bins, HTK. Whisper v3: 128 mel bins, Slaney, 512-FFT, no DC removal. +inline void init_whisper_mel_filters(cactus::engine::AudioProcessor& ap, + cactus::engine::AudioProcessor::SpectrogramConfig& cfg, + size_t mel_bins) { + const size_t num_mel_filters = std::max(1, mel_bins); + const bool is_v3 = mel_bins > 80; + if (is_v3) { + cfg.fft_override = 512; + cfg.remove_dc_offset = false; + } + const size_t fft_len = cfg.fft_override > 0 ? cfg.fft_override : cfg.n_fft; + const size_t num_frequency_bins = fft_len / 2 + 1; + if (is_v3) { + ap.init_mel_filters(num_frequency_bins, num_mel_filters, 0.0f, 8000.0f, + WHISPER_SAMPLE_RATE, "slaney", "slaney"); + } else { + ap.init_mel_filters(num_frequency_bins, num_mel_filters, 0.0f, 8000.0f, + WHISPER_SAMPLE_RATE); + } +} + +// use_mel_floor_padding=true pads short audio with the normalized mel floor (required for v3). +inline std::vector normalize_whisper_mel(std::vector& mel, size_t n_mels, + bool use_mel_floor_padding = false) { + if (mel.empty() || n_mels == 0) return mel; + size_t n_frames = mel.size() / n_mels; + + float max_val = -std::numeric_limits::infinity(); + for (float v : mel) if (v > max_val) max_val = v; + + float min_allowed = max_val - 8.0f; + for (float& v : mel) { + if (v < min_allowed) v = min_allowed; + v = (v + 4.0f) * 0.25f; + } + + if (n_frames != WHISPER_TARGET_FRAMES) { + float pad_val = use_mel_floor_padding ? (min_allowed + 4.0f) * 0.25f : 0.0f; + std::vector fixed(n_mels * WHISPER_TARGET_FRAMES, pad_val); + size_t copy_frames = std::min(n_frames, WHISPER_TARGET_FRAMES); + for (size_t m = 0; m < n_mels; ++m) { + const float* src = &mel[m * n_frames]; + float* dst = &fixed[m * WHISPER_TARGET_FRAMES]; + std::copy(src, src + copy_frames, dst); + } + return fixed; + } + return std::move(mel); +} + +inline std::vector transpose_mel_to_frame_major(const std::vector& mel, + size_t num_mels, size_t num_frames) { + std::vector transposed(num_frames * num_mels); + for (size_t m = 0; m < num_mels; m++) { + for (size_t t = 0; t < num_frames; t++) { + transposed[t * num_mels + m] = mel[m * num_frames + t]; + } + } + return transposed; +} + +inline void apply_preemphasis(std::vector& waveform, float coefficient = 0.97f) { + if (waveform.size() < 2 || coefficient == 0.0f) { + return; + } + for (size_t i = waveform.size() - 1; i > 0; --i) { + waveform[i] -= coefficient * waveform[i - 1]; + } +} + +inline void normalize_parakeet_log_mel(std::vector& mel, size_t num_mels, float epsilon = 1e-5f) { + if (mel.empty() || num_mels == 0 || (mel.size() % num_mels) != 0) { + return; + } + const size_t num_frames = mel.size() / num_mels; + if (num_frames == 0) { + return; + } + + for (size_t m = 0; m < num_mels; ++m) { + const size_t base = m * num_frames; + float mean = 0.0f; + for (size_t t = 0; t < num_frames; ++t) { + mean += mel[base + t]; + } + mean /= static_cast(num_frames); + + float variance = 0.0f; + for (size_t t = 0; t < num_frames; ++t) { + const float d = mel[base + t] - mean; + variance += d * d; + } + const float denom = static_cast(std::max(1, num_frames - 1)); + const float inv_std = 1.0f / std::sqrt((variance / denom) + epsilon); + for (size_t t = 0; t < num_frames; ++t) { + mel[base + t] = (mel[base + t] - mean) * inv_std; + } + } +} + +inline void trim_mel_frames(std::vector& mel, size_t num_mels, size_t valid_frames) { + if (mel.empty() || num_mels == 0 || (mel.size() % num_mels) != 0) { + return; + } + size_t total_frames = mel.size() / num_mels; + if (valid_frames == 0 || valid_frames >= total_frames) { + return; + } + std::vector trimmed(num_mels * valid_frames); + for (size_t m = 0; m < num_mels; ++m) { + const float* src = &mel[m * total_frames]; + float* dst = &trimmed[m * valid_frames]; + std::copy(src, src + valid_frames, dst); + } + mel.swap(trimmed); +} + +struct AudioPreprocessResult { + std::vector features; + size_t num_frames = 0; + size_t num_soft_tokens = 0; +}; + +inline AudioPreprocessResult preprocess_audio_for_gemma4( + std::vector audio_samples, + const cactus::engine::Config& model_config +) { + AudioPreprocessResult result; + if (audio_samples.empty()) return result; + + size_t pad_amt = 320 - (audio_samples.size() % 320); + if (pad_amt < 320) + audio_samples.resize(audio_samples.size() + pad_amt, 0.0f); + + size_t mel_bins = model_config.audio_input_feat_size; + auto cfg = get_gemma4_audio_spectrogram_config(model_config); + + size_t semicausal_pad = cfg.frame_length / 2; + audio_samples.insert(audio_samples.begin(), semicausal_pad, 0.0f); + + cactus::engine::AudioProcessor ap; + size_t fft_for_mel = cfg.fft_override > 0 ? cfg.fft_override : cfg.n_fft; + ap.init_mel_filters(fft_for_mel / 2 + 1, mel_bins, 0.0f, 8000.0f, 16000, + nullptr, "htk"); + std::vector mel = ap.compute_spectrogram(audio_samples, cfg); + + result.num_frames = mel.size() / mel_bins; + result.features = transpose_mel_to_frame_major(mel, mel_bins, result.num_frames); + + size_t after_stage1 = (result.num_frames + 1) / 2; + result.num_soft_tokens = (after_stage1 + 1) / 2; + + return result; +} + +inline std::vector pcm_buffer_to_float_samples( + const uint8_t* pcm_buffer, size_t pcm_buffer_size +) { + const int16_t* pcm_samples = reinterpret_cast(pcm_buffer); + size_t num_samples = pcm_buffer_size / 2; + std::vector waveform_fp32(num_samples); + constexpr float inv_32768 = 1.0f / 32768.0f; + for (size_t i = 0; i < num_samples; i++) + waveform_fp32[i] = static_cast(pcm_samples[i]) * inv_32768; + return waveform_fp32; +} + +} // namespace audio +} // namespace cactus + +namespace cactus { +namespace ffi { + +inline bool env_flag_enabled(const char* key) { + const char* value = std::getenv(key); + return value && value[0] != '\0' && !(value[0] == '0' && value[1] == '\0'); +} + +inline std::string generateUUID() { +#ifdef __APPLE__ + uuid_t uuid; + uuid_generate_random(uuid); + char uuid_str[37]; + uuid_unparse_lower(uuid, uuid_str); + return std::string(uuid_str); +#else + static std::random_device rd; + static std::mt19937 gen(rd()); + static std::uniform_int_distribution<> dis(0, 15); + static std::uniform_int_distribution<> dis2(8, 11); + + std::stringstream ss; + ss << std::hex; + for (int i = 0; i < 8; i++) ss << dis(gen); + ss << "-"; + for (int i = 0; i < 4; i++) ss << dis(gen); + ss << "-4"; + for (int i = 0; i < 3; i++) ss << dis(gen); + ss << "-"; + ss << dis2(gen); + for (int i = 0; i < 3; i++) ss << dis(gen); + ss << "-"; + for (int i = 0; i < 12; i++) ss << dis(gen); + return ss.str(); +#endif +} + +struct ToolFunction { + std::string name; + std::string description; + std::unordered_map parameters; +}; + +struct InferenceOptions { + float temperature = 0.0f; + float top_p = 0.0f; + float min_p = 0.15f; + float repetition_penalty = 1.1f; + float confidence_threshold = 0.7f; + size_t top_k = 0; + size_t max_tokens = 100; + size_t tool_rag_top_k = 2; + size_t cloud_timeout_ms = 15000; + std::vector stop_sequences; + bool force_tools = false; + bool include_stop_sequences = false; + bool use_vad = true; + bool telemetry_enabled = true; + bool auto_handoff = true; + bool handoff_with_images = true; + bool enable_thinking_if_supported = false; +}; + +} // namespace ffi +} // namespace cactus + +std::vector select_relevant_tools( + CactusModelHandle* handle, + const std::string& query, + const std::vector& all_tools, + size_t top_k); + +#include "gemma_tools.h" + +namespace cactus { +namespace ffi { + +inline std::string escape_json_string(const std::string& s) { + std::ostringstream o; + for (char c : s) { + if (c == '"') o << "\\\""; + else if (c == '\n') o << "\\n"; + else if (c == '\r') o << "\\r"; + else if (c == '\t') o << "\\t"; + else if (c == '\\') o << "\\\\"; + else o << c; + } + return o.str(); +} + + +inline std::string trim_string(const std::string& s) { + size_t start = 0; + while (start < s.size() && std::isspace(static_cast(s[start]))) ++start; + size_t end = s.size(); + while (end > start && std::isspace(static_cast(s[end - 1]))) --end; + return s.substr(start, end - start); +} + +inline size_t find_matching_delimiter(const std::string& s, size_t pos, char open, char close) { + int depth = 1; + pos++; + while (pos < s.length() && depth > 0) { + if (s[pos] == open) depth++; + else if (s[pos] == close) depth--; + else if (s[pos] == '"') { + pos++; + while (pos < s.length() && s[pos] != '"') { + if (s[pos] == '\\') pos++; + pos++; + } + } + pos++; + } + return pos; +} + +inline std::string env_or_default(const char* key, const char* fallback) { + const char* v = std::getenv(key); + if (v && v[0] != '\0') return std::string(v); + return std::string(fallback); +} + +inline std::string json_string_field(const std::string& json, const std::string& key) { + std::string pattern = "\"" + key + "\":"; + size_t pos = json.find(pattern); + if (pos == std::string::npos) return {}; + + size_t i = pos + pattern.size(); + while (i < json.size() && std::isspace(static_cast(json[i]))) i++; + if (i >= json.size() || json[i] != '"') return {}; + ++i; + + std::string out; + out.reserve(128); + while (i < json.size()) { + char c = json[i++]; + if (c == '"') return out; + if (c == '\\' && i < json.size()) { + char e = json[i++]; + switch (e) { + case '"': out.push_back('"'); break; + case '\\': out.push_back('\\'); break; + case '/': out.push_back('/'); break; + case 'b': out.push_back('\b'); break; + case 'f': out.push_back('\f'); break; + case 'n': out.push_back('\n'); break; + case 'r': out.push_back('\r'); break; + case 't': out.push_back('\t'); break; + default: out.push_back(e); break; + } + continue; + } + out.push_back(c); + } + return {}; +} + +inline std::string json_array_field(const std::string& json, const std::string& key) { + std::string pattern = "\"" + key + "\":"; + size_t pos = json.find(pattern); + if (pos == std::string::npos) return "[]"; + size_t start = pos + pattern.size(); + while (start < json.size() && std::isspace(static_cast(json[start]))) ++start; + if (start >= json.size() || json[start] != '[') return "[]"; + + int depth = 1; + size_t end = start + 1; + while (end < json.size() && depth > 0) { + if (json[end] == '[') depth++; + else if (json[end] == ']') depth--; + end++; + } + return json.substr(start, end - start); +} + +inline std::vector split_json_array(const std::string& array_json) { + std::vector out; + if (array_json.size() < 2 || array_json.front() != '[' || array_json.back() != ']') return out; + + size_t i = 1; + while (i + 1 < array_json.size()) { + while (i + 1 < array_json.size() && + (std::isspace(static_cast(array_json[i])) || array_json[i] == ',')) i++; + if (i + 1 >= array_json.size() || array_json[i] != '{') break; + + size_t start = i; + int depth = 0; + bool in_str = false; + bool esc = false; + for (; i < array_json.size(); ++i) { + char c = array_json[i]; + if (in_str) { + if (esc) esc = false; + else if (c == '\\') esc = true; + else if (c == '"') in_str = false; + continue; + } + if (c == '"') { in_str = true; continue; } + if (c == '{') depth++; + if (c == '}') { + depth--; + if (depth == 0) { + out.push_back(array_json.substr(start, i - start + 1)); + i++; + break; + } + } + } + } + return out; +} + +inline std::string serialize_tools_json(const std::vector& tools) { + if (tools.empty()) return ""; + std::ostringstream oss; + oss << "["; + for (size_t i = 0; i < tools.size(); ++i) { + if (i > 0) oss << ","; + oss << "{\"type\":\"function\",\"function\":{"; + oss << "\"name\":\"" << escape_json_string(tools[i].name) << "\","; + oss << "\"description\":\"" << escape_json_string(tools[i].description) << "\""; + auto it = tools[i].parameters.find("schema"); + if (it != tools[i].parameters.end()) { + oss << ",\"parameters\":" << it->second; + } + oss << "}}"; + } + oss << "]"; + return oss.str(); +} + +namespace json_sorted { + +inline void skip_ws(const std::string& s, size_t& p) { + while (p < s.size() && std::isspace(static_cast(s[p]))) p++; +} + +inline std::string parse_string(const std::string& s, size_t& p) { + std::string r = "\""; + p++; + while (p < s.size()) { + if (s[p] == '\\') { + r += s[p++]; + if (p < s.size()) r += s[p++]; + } else if (s[p] == '"') { + r += '"'; + p++; + return r; + } else { + r += s[p++]; + } + } + return r; +} + +inline std::string parse_value(const std::string& s, size_t& p); + +inline std::string parse_object(const std::string& s, size_t& p) { + p++; + std::map entries; + skip_ws(s, p); + while (p < s.size() && s[p] != '}') { + if (s[p] == ',') { p++; skip_ws(s, p); continue; } + std::string key = parse_string(s, p); + skip_ws(s, p); + if (p < s.size() && s[p] == ':') p++; + skip_ws(s, p); + std::string val = parse_value(s, p); + entries[key] = val; + skip_ws(s, p); + } + if (p < s.size()) p++; + std::string r = "{"; + bool first = true; + for (const auto& kv : entries) { + if (!first) r += ", "; + r += kv.first + ": " + kv.second; + first = false; + } + r += "}"; + return r; +} + +inline std::string parse_array(const std::string& s, size_t& p) { + p++; + std::vector items; + skip_ws(s, p); + while (p < s.size() && s[p] != ']') { + if (s[p] == ',') { p++; skip_ws(s, p); continue; } + items.push_back(parse_value(s, p)); + skip_ws(s, p); + } + if (p < s.size()) p++; + std::string r = "["; + for (size_t i = 0; i < items.size(); i++) { + if (i > 0) r += ", "; + r += items[i]; + } + r += "]"; + return r; +} + +inline std::string parse_value(const std::string& s, size_t& p) { + skip_ws(s, p); + if (p >= s.size()) return ""; + if (s[p] == '"') return parse_string(s, p); + if (s[p] == '{') return parse_object(s, p); + if (s[p] == '[') return parse_array(s, p); + size_t start = p; + while (p < s.size() && s[p] != ',' && s[p] != '}' && s[p] != ']' && !std::isspace(static_cast(s[p]))) p++; + return s.substr(start, p - start); +} + +inline std::string reformat(const std::string& json) { + size_t p = 0; + return parse_value(json, p); +} + +} // namespace json_sorted + +inline std::string serialize_tools_for_template(const std::vector& tools) { + if (tools.empty()) return ""; + std::string result; + for (const auto& tool : tools) { + std::map func_fields; + func_fields["\"description\""] = "\"" + escape_json_string(tool.description) + "\""; + func_fields["\"name\""] = "\"" + escape_json_string(tool.name) + "\""; + auto it = tool.parameters.find("schema"); + if (it != tool.parameters.end()) { + func_fields["\"parameters\""] = json_sorted::reformat(it->second); + } + std::string func_json = "{"; + bool first = true; + for (const auto& kv : func_fields) { + if (!first) func_json += ", "; + func_json += kv.first + ": " + kv.second; + first = false; + } + func_json += "}"; + result += "\n{\"function\": " + func_json + ", \"type\": \"function\"}"; + } + return result; +} + +inline void handle_error_response(const std::string& error_message, char* response_buffer, size_t buffer_size) { + std::ostringstream json; + json << "{"; + json << "\"success\":false,"; + json << "\"error\":\"" << escape_json_string(error_message) << "\","; + json << "\"cloud_handoff\":false,"; + json << "\"response\":null,"; + json << "\"function_calls\":[],"; + json << "\"confidence\":0.0,"; + json << "\"time_to_first_token_ms\":0.0,"; + json << "\"total_time_ms\":0.0,"; + json << "\"prefill_tps\":0.0,"; + json << "\"decode_tps\":0.0,"; + json << "\"ram_usage_mb\":" << std::fixed << std::setprecision(2) << get_ram_usage_mb() << ","; + json << "\"prefill_tokens\":0,"; + json << "\"decode_tokens\":0,"; + json << "\"total_tokens\":0"; + json << "}"; + std::string error_json = json.str(); + if (response_buffer && error_json.length() < buffer_size) { + std::strcpy(response_buffer, error_json.c_str()); + } +} + +inline std::vector parse_messages_json(const std::string& json, + std::vector& out_image_paths, + std::vector* out_audio_paths = nullptr) { + std::vector messages; + out_image_paths.clear(); + if (out_audio_paths) out_audio_paths->clear(); + + size_t pos = json.find('['); + if (pos == std::string::npos) { + throw std::runtime_error("Invalid JSON: expected array"); + } + + pos = json.find('{', pos); + while (pos != std::string::npos) { + cactus::engine::ChatMessage msg; + + size_t obj_start = pos; + int brace_count = 1; + size_t obj_end = obj_start + 1; + while (obj_end < json.length() && brace_count > 0) { + if (json[obj_end] == '{') brace_count++; + else if (json[obj_end] == '}') brace_count--; + obj_end++; + } + + size_t role_pos = json.find("\"role\"", pos); + if (role_pos == std::string::npos || role_pos >= obj_end) break; + + size_t role_start = json.find('"', role_pos + 6) + 1; + size_t role_end = json.find('"', role_start); + msg.role = json.substr(role_start, role_end - role_start); + + size_t content_pos = json.find("\"content\"", role_end); + if (content_pos != std::string::npos && content_pos < obj_end) { + size_t content_start = json.find('"', content_pos + 9) + 1; + size_t content_end = content_start; + + while (content_end < json.length()) { + content_end = json.find('"', content_end); + if (content_end == std::string::npos) break; + if (json[content_end - 1] != '\\') break; + content_end++; + } + + msg.content = json.substr(content_start, content_end - content_start); + + size_t escape_pos = 0; + while ((escape_pos = msg.content.find("\\n", escape_pos)) != std::string::npos) { + msg.content.replace(escape_pos, 2, "\n"); + escape_pos += 1; + } + escape_pos = 0; + while ((escape_pos = msg.content.find("\\\"", escape_pos)) != std::string::npos) { + msg.content.replace(escape_pos, 2, "\""); + escape_pos += 1; + } + } + + auto parse_path_array = [&](const char* key, std::vector& dest, + std::vector* out_paths) { + size_t key_pos = json.find(key, pos); + if (key_pos == std::string::npos || key_pos >= obj_end) return; + size_t array_start = json.find('[', key_pos); + if (array_start == std::string::npos || array_start >= obj_end) return; + size_t array_end = json.find(']', array_start); + if (array_end == std::string::npos || array_end >= obj_end) return; + size_t cur = array_start; + while (true) { + cur = json.find('"', cur + 1); + if (cur == std::string::npos || cur >= array_end) break; + size_t str_start = cur + 1; + size_t str_end = json.find('"', str_start); + if (str_end == std::string::npos || str_end > array_end) break; + std::string path = std::filesystem::absolute( + std::filesystem::path(json.substr(str_start, str_end - str_start))).string(); + dest.push_back(path); + if (out_paths) out_paths->push_back(path); + cur = str_end; + } + }; + + parse_path_array("\"images\"", msg.images, &out_image_paths); + parse_path_array("\"audio\"", msg.audio, out_audio_paths); + + if (msg.role == "tool") { + size_t name_pos = json.find("\"name\"", obj_start); + if (name_pos != std::string::npos && name_pos < obj_end) { + size_t name_quote = json.find('"', name_pos + 6); + if (name_quote != std::string::npos && name_quote < obj_end) { + size_t name_start = name_quote + 1; + size_t name_end = json.find('"', name_start); + if (name_end != std::string::npos && name_end < obj_end) { + msg.name = json.substr(name_start, name_end - name_start); + } + } + } + } + + size_t tool_calls_pos = json.find("\"tool_calls\"", obj_start); + if (tool_calls_pos != std::string::npos && tool_calls_pos < obj_end) { + size_t tool_calls_arr_start = json.find('[', tool_calls_pos); + if (tool_calls_arr_start != std::string::npos && tool_calls_arr_start < obj_end) { + size_t tool_calls_arr_end = find_matching_delimiter(json, tool_calls_arr_start, '[', ']'); + + size_t search_pos = tool_calls_arr_start; + while (true) { + size_t func_pos = json.find("\"function\"", search_pos); + if (func_pos == std::string::npos || func_pos >= tool_calls_arr_end) break; + + size_t func_obj_start = json.find('{', func_pos + 10); + if (func_obj_start == std::string::npos || func_obj_start >= tool_calls_arr_end) break; + + size_t func_obj_end = find_matching_delimiter(json, func_obj_start, '{', '}'); + + cactus::engine::ToolCallInfo tool_call; + + size_t fn_name_pos = json.find("\"name\"", func_obj_start); + if (fn_name_pos != std::string::npos && fn_name_pos < func_obj_end) { + size_t fn_name_quote = json.find('"', fn_name_pos + 6); + if (fn_name_quote != std::string::npos && fn_name_quote < func_obj_end) { + size_t fn_name_start = fn_name_quote + 1; + size_t fn_name_end = json.find('"', fn_name_start); + if (fn_name_end != std::string::npos && fn_name_end < func_obj_end) { + tool_call.name = json.substr(fn_name_start, fn_name_end - fn_name_start); + } + } + } + + size_t args_pos = json.find("\"arguments\"", func_obj_start); + if (args_pos != std::string::npos && args_pos < func_obj_end) { + size_t colon_pos = json.find(':', args_pos + 11); + if (colon_pos != std::string::npos && colon_pos < func_obj_end) { + size_t args_start = colon_pos + 1; + while (args_start < json.length() && std::isspace(static_cast(json[args_start]))) args_start++; + + if (args_start < func_obj_end && json[args_start] == '{') { + size_t args_end = find_matching_delimiter(json, args_start, '{', '}'); + tool_call.arguments = json.substr(args_start, args_end - args_start); + } else if (args_start < func_obj_end && json[args_start] == '"') { + size_t str_start = args_start + 1; + size_t str_end = str_start; + while (str_end < json.length() && json[str_end] != '"') { + if (json[str_end] == '\\') str_end++; + str_end++; + } + tool_call.arguments = json.substr(str_start, str_end - str_start); + } + } + } + + if (!tool_call.name.empty()) { + msg.tool_calls.push_back(tool_call); + } + search_pos = func_obj_end; + } + } + } + + messages.push_back(msg); + + pos = json.find('{', obj_end); + } + + return messages; +} + +inline std::vector parse_tools_json(const std::string& json) { + std::vector tools; + + if (json.empty()) return tools; + + size_t pos = json.find('['); + if (pos == std::string::npos) return tools; + + pos = json.find("\"function\"", pos); + while (pos != std::string::npos) { + ToolFunction tool; + + size_t name_pos = json.find("\"name\"", pos); + if (name_pos != std::string::npos) { + size_t name_start = json.find('"', name_pos + 6) + 1; + size_t name_end = json.find('"', name_start); + tool.name = json.substr(name_start, name_end - name_start); + } + + size_t desc_pos = json.find("\"description\"", pos); + if (desc_pos != std::string::npos) { + size_t desc_start = json.find('"', desc_pos + 13) + 1; + size_t desc_end = json.find('"', desc_start); + tool.description = json.substr(desc_start, desc_end - desc_start); + } + + size_t params_pos = json.find("\"parameters\"", pos); + if (params_pos != std::string::npos) { + size_t params_start = json.find('{', params_pos); + if (params_start != std::string::npos) { + int brace_count = 1; + size_t params_end = params_start + 1; + while (params_end < json.length() && brace_count > 0) { + if (json[params_end] == '{') brace_count++; + else if (json[params_end] == '}') brace_count--; + params_end++; + } + tool.parameters["schema"] = json.substr(params_start, params_end - params_start); + } + } + + tools.push_back(tool); + + pos = json.find("\"function\"", name_pos); + } + + return tools; +} + +inline bool try_parse_json_float(const std::string& json, const std::string& key, float& out_value) { + std::string pattern = "\"" + key + "\":"; + size_t pos = json.find(pattern); + if (pos == std::string::npos) return false; + + size_t start = pos + pattern.size(); + while (start < json.size() && std::isspace(static_cast(json[start]))) ++start; + + size_t end = start; + while (end < json.size() && std::string(",}] \t\n\r").find(json[end]) == std::string::npos) ++end; + + try { + out_value = std::stof(json.substr(start, end - start)); + return true; + } catch (...) { + return false; + } +} + +inline std::vector parse_json_string_array_field(const std::string& json, const std::string& key) { + std::vector out; + std::string pattern = "\"" + key + "\":"; + size_t pos = json.find(pattern); + if (pos == std::string::npos) return out; + + size_t start = pos + pattern.size(); + while (start < json.size() && std::isspace(static_cast(json[start]))) ++start; + if (start >= json.size() || json[start] != '[') return out; + + int depth = 1; + bool in_string = false; + bool escaped = false; + size_t end = start + 1; + + while (end < json.size() && depth > 0) { + char c = json[end]; + if (in_string) { + if (escaped) escaped = false; + else if (c == '\\') escaped = true; + else if (c == '"') in_string = false; + } else { + if (c == '"') in_string = true; + else if (c == '[') depth++; + else if (c == ']') depth--; + } + ++end; + } + + if (depth != 0) return out; + const std::string array_json = json.substr(start, end - start); + if (array_json.size() < 2 || array_json.front() != '[' || array_json.back() != ']') return out; + + size_t i = 1; + while (i + 1 < array_json.size()) { + while (i + 1 < array_json.size() && + (std::isspace(static_cast(array_json[i])) || array_json[i] == ',')) { + ++i; + } + if (i + 1 >= array_json.size() || array_json[i] == ']') break; + if (array_json[i] != '"') break; + + ++i; + std::string value; + bool escaped = false; + while (i < array_json.size()) { + char c = array_json[i++]; + if (escaped) { + switch (c) { + case '"': value.push_back('"'); break; + case '\\': value.push_back('\\'); break; + case '/': value.push_back('/'); break; + case 'b': value.push_back('\b'); break; + case 'f': value.push_back('\f'); break; + case 'n': value.push_back('\n'); break; + case 'r': value.push_back('\r'); break; + case 't': value.push_back('\t'); break; + default: value.push_back(c); break; + } + escaped = false; + continue; + } + if (c == '\\') { + escaped = true; + continue; + } + if (c == '"') { + out.push_back(value); + break; + } + value.push_back(c); + } + } + + return out; +} + +inline void parse_custom_vocabulary_options(const std::string& json, + std::vector& custom_vocabulary, + float& vocabulary_boost) { + custom_vocabulary.clear(); + vocabulary_boost = 5.0f; + if (json.empty()) return; + + float parsed_boost = vocabulary_boost; + if (try_parse_json_float(json, "vocabulary_boost", parsed_boost)) { + vocabulary_boost = std::clamp(parsed_boost, 0.0f, 20.0f); + } + + custom_vocabulary = parse_json_string_array_field(json, "custom_vocabulary"); +} + +inline std::unordered_map build_token_bias_map(const std::vector>& tokenized_entries, + float vocabulary_boost) { + std::unordered_map vocab_bias; + const float clamped_boost = std::clamp(vocabulary_boost, 0.0f, 20.0f); + if (clamped_boost == 0.0f) return vocab_bias; + + for (const auto& token_ids : tokenized_entries) { + for (uint32_t token_id : token_ids) { + float& entry = vocab_bias[token_id]; + if (entry < clamped_boost) { + entry = clamped_boost; + } + } + } + + return vocab_bias; +} + +inline std::unordered_map build_custom_vocabulary_bias(cactus::engine::Tokenizer* tokenizer, + const std::vector& custom_vocabulary, + float vocabulary_boost) { + if (!tokenizer || custom_vocabulary.empty()) return {}; + std::vector> tokenized_entries; + tokenized_entries.reserve(custom_vocabulary.size()); + + for (const auto& word : custom_vocabulary) { + if (word.empty()) continue; + tokenized_entries.push_back(tokenizer->encode(word)); + } + + return build_token_bias_map(tokenized_entries, vocabulary_boost); +} + +inline void apply_custom_vocabulary_options(cactus::engine::Model* model, const std::string& json) { + if (!model) return; + + std::vector custom_vocabulary; + float vocabulary_boost = 5.0f; + parse_custom_vocabulary_options(json, custom_vocabulary, vocabulary_boost); + model->set_vocab_bias(build_custom_vocabulary_bias(model->get_tokenizer(), custom_vocabulary, vocabulary_boost)); +} + +inline size_t levenshtein_ci(const std::string& a, const std::string& b) { + const size_t m = a.size(), n = b.size(); + std::vector prev(n + 1), curr(n + 1); + for (size_t j = 0; j <= n; ++j) prev[j] = j; + for (size_t i = 1; i <= m; ++i) { + curr[0] = i; + for (size_t j = 1; j <= n; ++j) { + const bool match = std::tolower(static_cast(a[i - 1])) == + std::tolower(static_cast(b[j - 1])); + curr[j] = std::min({prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + (match ? 0 : 1)}); + } + std::swap(prev, curr); + } + return prev[n]; +} + +inline std::string collapse_spaces(const std::string& s) { + std::string out; + out.reserve(s.size()); + for (char c : s) { + if (c != ' ') out += c; + } + return out; +} + +inline void apply_vocabulary_spelling_correction( + std::string& text, + const std::vector& custom_vocabulary) +{ + if (custom_vocabulary.empty() || text.empty()) return; + + struct VocabEntry { + const std::string* original; + std::string collapsed; + }; + std::vector vocab_entries; + vocab_entries.reserve(custom_vocabulary.size()); + for (const auto& v : custom_vocabulary) { + vocab_entries.push_back({&v, collapse_spaces(v)}); + } + + struct Token { std::string text; bool is_word; }; + std::vector tokens; + size_t pos = 0; + while (pos < text.size()) { + if (std::isalnum(static_cast(text[pos])) || + text[pos] == '\'' || text[pos] == '-') { + size_t start = pos; + while (pos < text.size() && (std::isalnum(static_cast(text[pos])) || + text[pos] == '\'' || text[pos] == '-')) { + ++pos; + } + tokens.push_back({text.substr(start, pos - start), true}); + } else { + size_t start = pos; + while (pos < text.size() && !std::isalnum(static_cast(text[pos])) && + text[pos] != '\'' && text[pos] != '-') { + ++pos; + } + tokens.push_back({text.substr(start, pos - start), false}); + } + } + + std::vector word_indices; + for (size_t i = 0; i < tokens.size(); ++i) { + if (tokens[i].is_word) word_indices.push_back(i); + } + + std::vector consumed(tokens.size(), false); + + auto strip_suffix = [](const std::string& word) -> std::pair { + if (word.size() >= 3 && word.substr(word.size() - 2) == "'s") { + return {word.substr(0, word.size() - 2), "'s"}; + } + if (word.size() >= 3 && word.substr(word.size() - 2) == "'t") { + return {word.substr(0, word.size() - 2), "'t"}; + } + if (word.size() >= 4 && word.back() == 's' && + word[word.size() - 2] != 's' && // avoid stripping from "boss", "class" + std::isalpha(static_cast(word[word.size() - 2]))) { + return {word.substr(0, word.size() - 1), "s"}; + } + return {word, ""}; + }; + + size_t wi = 0; + while (wi < word_indices.size()) { + size_t best_dist = std::numeric_limits::max(); + const std::string* best_match = nullptr; + size_t best_window = 0; + size_t best_first_token = 0; + size_t best_last_token = 0; + std::string best_suffix; + + for (size_t window = std::min(3, word_indices.size() - wi); window >= 1; --window) { + std::string window_collapsed; + const size_t first_tok = word_indices[wi]; + const size_t last_tok = word_indices[wi + window - 1]; + for (size_t w = 0; w < window; ++w) { + window_collapsed += tokens[word_indices[wi + w]].text; + } + + if (window == 1 && window_collapsed.size() < 3) break; + + auto [stem, suffix] = strip_suffix(window_collapsed); + const std::string* candidates[] = {&window_collapsed, &stem}; + const std::string suffixes[] = {"", suffix}; + const size_t num_candidates = suffix.empty() ? 1 : 2; + + for (size_t ci = 0; ci < num_candidates; ++ci) { + const std::string& candidate = *candidates[ci]; + if (candidate.empty()) continue; + + for (const auto& entry : vocab_entries) { + const size_t wlen = candidate.size(); + const size_t vlen = entry.collapsed.size(); + + const size_t len_diff = wlen > vlen ? wlen - vlen : vlen - wlen; + const size_t max_dist = std::max(1, std::min(wlen, vlen) / 3); + if (len_diff > max_dist) continue; + + const size_t dist = levenshtein_ci(candidate, entry.collapsed); + + // For single-edit corrections, require first char match to prevent + // false positives like "vortex" → "Cortex". + if (dist == 1 && window == 1) { + const bool first_char_match = + std::tolower(static_cast(candidate[0])) == + std::tolower(static_cast(entry.collapsed[0])); + if (!first_char_match) continue; + } + + if (dist <= max_dist && dist < best_dist) { + best_dist = dist; + best_match = entry.original; + best_window = window; + best_first_token = first_tok; + best_last_token = last_tok; + best_suffix = suffixes[ci]; + } + } + } + + if (best_dist == 0) break; + } + + // Allow dist==0 for multi-word merges where word boundaries changed. + const bool should_replace = best_match && + best_dist != std::numeric_limits::max() && + (best_dist > 0 || best_window > 1); + + if (should_replace) { + tokens[best_first_token].text = *best_match + best_suffix; + for (size_t t = best_first_token + 1; t <= best_last_token; ++t) { + consumed[t] = true; + } + for (size_t t = best_first_token + 1; t <= best_last_token; ++t) { + if (t > 0) consumed[t - 1] = consumed[t - 1] || !tokens[t - 1].is_word; + } + wi += best_window; + } else { + ++wi; + } + } + + std::string result; + result.reserve(text.size()); + for (size_t i = 0; i < tokens.size(); ++i) { + if (!consumed[i]) { + result += tokens[i].text; + } + } + + text = std::move(result); +} + +inline InferenceOptions parse_inference_options_json(const std::string& json) { + InferenceOptions options; + + if (json.empty()) return options; + + size_t pos = json.find("\"temperature\""); + if (pos != std::string::npos) { + pos = json.find(':', pos) + 1; + options.temperature = std::stof(json.substr(pos)); + } + + pos = json.find("\"top_p\""); + if (pos != std::string::npos) { + pos = json.find(':', pos) + 1; + options.top_p = std::stof(json.substr(pos)); + } + + float parsed_min_p = options.min_p; + if (try_parse_json_float(json, "min_p", parsed_min_p)) { + options.min_p = std::clamp(parsed_min_p, 0.0f, 1.0f); + } + + float parsed_rep_penalty = options.repetition_penalty; + if (try_parse_json_float(json, "repetition_penalty", parsed_rep_penalty)) { + if (std::isfinite(parsed_rep_penalty) && parsed_rep_penalty > 0.0f) { + options.repetition_penalty = parsed_rep_penalty; + } + } + + pos = json.find("\"top_k\""); + if (pos != std::string::npos) { + pos = json.find(':', pos) + 1; + options.top_k = std::stoul(json.substr(pos)); + } + + pos = json.find("\"max_tokens\""); + if (pos != std::string::npos) { + pos = json.find(':', pos) + 1; + options.max_tokens = std::stoul(json.substr(pos)); + } + + pos = json.find("\"force_tools\""); + if (pos != std::string::npos) { + pos = json.find(':', pos) + 1; + while (pos < json.length() && std::isspace(static_cast(json[pos]))) pos++; + options.force_tools = (json.substr(pos, 4) == "true"); + } + + pos = json.find("\"tool_rag_top_k\""); + if (pos != std::string::npos) { + pos = json.find(':', pos) + 1; + options.tool_rag_top_k = std::stoul(json.substr(pos)); + } + + pos = json.find("\"confidence_threshold\""); + if (pos != std::string::npos) { + pos = json.find(':', pos) + 1; + options.confidence_threshold = std::stof(json.substr(pos)); + } + + pos = json.find("\"include_stop_sequences\""); + if (pos != std::string::npos) { + pos = json.find(':', pos) + 1; + while (pos < json.length() && std::isspace(static_cast(json[pos]))) pos++; + options.include_stop_sequences = (json.substr(pos, 4) == "true"); + } + + pos = json.find("\"use_vad\""); + if (pos != std::string::npos) { + pos = json.find(':', pos) + 1; + while (pos < json.length() && std::isspace(static_cast(json[pos]))) pos++; + options.use_vad = (json.substr(pos, 4) == "true"); + } + + pos = json.find("\"telemetry_enabled\""); + if (pos != std::string::npos) { + pos = json.find(':', pos) + 1; + while (pos < json.length() && std::isspace(static_cast(json[pos]))) pos++; + options.telemetry_enabled = (json.substr(pos, 4) == "true"); + } + + pos = json.find("\"auto_handoff\""); + if (pos != std::string::npos) { + pos = json.find(':', pos) + 1; + while (pos < json.length() && std::isspace(static_cast(json[pos]))) pos++; + options.auto_handoff = (json.substr(pos, 4) == "true"); + } + + pos = json.find("\"cloud_timeout_ms\""); + if (pos != std::string::npos) { + pos = json.find(':', pos) + 1; + options.cloud_timeout_ms = std::stoul(json.substr(pos)); + } + + pos = json.find("\"handoff_with_images\""); + if (pos != std::string::npos) { + pos = json.find(':', pos) + 1; + while (pos < json.length() && std::isspace(static_cast(json[pos]))) pos++; + options.handoff_with_images = (json.substr(pos, 4) == "true"); + } + + pos = json.find("\"enable_thinking_if_supported\""); + if (pos != std::string::npos) { + pos = json.find(':', pos) + 1; + while (pos < json.length() && std::isspace(static_cast(json[pos]))) pos++; + options.enable_thinking_if_supported = (json.substr(pos, 4) == "true"); + } + + pos = json.find("\"stop_sequences\""); + if (pos != std::string::npos) { + pos = json.find('[', pos); + if (pos != std::string::npos) { + size_t end_pos = json.find(']', pos); + size_t seq_pos = json.find('"', pos); + + while (seq_pos != std::string::npos && seq_pos < end_pos) { + size_t seq_start = seq_pos + 1; + size_t seq_end = json.find('"', seq_start); + if (seq_end != std::string::npos) { + options.stop_sequences.push_back(json.substr(seq_start, seq_end - seq_start)); + } + seq_pos = json.find('"', seq_end + 1); + } + } + } + + return options; +} + +static inline std::string trim_lfm2_slice(const std::string& value, size_t begin, size_t end) { + return trim_string(value.substr(begin, end - begin)); +} + +static inline void append_lfm2_call(const std::string& entry, + std::vector& function_calls) { + if (entry.empty()) return; + + std::string trimmed_entry = trim_lfm2_slice(entry, 0, entry.size()); + if (trimmed_entry.empty()) return; + + size_t paren_pos = trimmed_entry.find('('); + if (paren_pos == std::string::npos) return; + + std::string func_name = trim_lfm2_slice(trimmed_entry, 0, paren_pos); + std::string args_str = trim_lfm2_slice(trimmed_entry, paren_pos + 1, trimmed_entry.size()); + + if (!args_str.empty() && args_str.back() == ')') { + args_str.pop_back(); + args_str = trim_lfm2_slice(args_str, 0, args_str.size()); + } + + std::string json_call = "{\"name\":\"" + func_name + "\",\"arguments\":{"; + + size_t arg_pos = 0; + bool first_arg = true; + while (arg_pos < args_str.length()) { + while (arg_pos < args_str.length() && std::isspace(static_cast(args_str[arg_pos]))) { + arg_pos++; + } + + size_t eq_pos = args_str.find('=', arg_pos); + if (eq_pos == std::string::npos) break; + + std::string arg_name = args_str.substr(arg_pos, eq_pos - arg_pos); + + size_t val_start = eq_pos + 1; + size_t val_end = val_start; + + if (val_start < args_str.length() && args_str[val_start] == '"') { + val_start++; + val_end = args_str.find('"', val_start); + if (val_end == std::string::npos) break; + } else { + val_end = args_str.find(',', val_start); + if (val_end == std::string::npos) val_end = args_str.length(); + } + + std::string arg_value = args_str.substr(val_start, val_end - val_start); + + if (!first_arg) json_call += ","; + json_call += "\"" + arg_name + "\":\"" + arg_value + "\""; + first_arg = false; + + arg_pos = args_str.find(',', val_end); + if (arg_pos != std::string::npos) { + arg_pos++; + } else { + break; + } + } + + json_call += "}}"; + function_calls.push_back(json_call); +} + +inline void parse_function_calls_from_response(const std::string& response_text, + std::string& regular_response, + std::vector& function_calls) { + regular_response = response_text; + function_calls.clear(); + + gemma::parse_function_calls(regular_response, function_calls); + + const std::string QWEN_TOOL_START = ""; + const std::string QWEN_TOOL_END = ""; + size_t qwen_start_pos = 0; + + while ((qwen_start_pos = regular_response.find(QWEN_TOOL_START, qwen_start_pos)) != std::string::npos) { + size_t content_start = qwen_start_pos + QWEN_TOOL_START.length(); + size_t qwen_end_pos = regular_response.find(QWEN_TOOL_END, content_start); + + size_t erase_end; + std::string json_content; + + if (qwen_end_pos != std::string::npos) { + json_content = regular_response.substr(content_start, qwen_end_pos - content_start); + erase_end = qwen_end_pos + QWEN_TOOL_END.length(); + } else { + json_content = regular_response.substr(content_start); + erase_end = regular_response.length(); + } + + size_t first = json_content.find_first_not_of(" \t\n\r"); + size_t last = json_content.find_last_not_of(" \t\n\r"); + if (first != std::string::npos && last != std::string::npos) { + json_content = json_content.substr(first, last - first + 1); + } + + if (json_content.size() > 2 && json_content.find("\"name\"") != std::string::npos) { + // Unwrap array wrapper if present: [{"name":...}] -> {"name":...} + if (json_content[0] == '[') { + size_t obj_start = json_content.find('{'); + size_t obj_end = json_content.rfind('}'); + if (obj_start != std::string::npos && obj_end != std::string::npos && obj_end > obj_start) { + json_content = json_content.substr(obj_start, obj_end - obj_start + 1); + } + } + if (json_content[0] == '{') { + size_t depth = 0; + bool in_string = false; + bool escaped = false; + size_t end_pos = 0; + for (size_t c = 0; c < json_content.size(); c++) { + char ch = json_content[c]; + if (escaped) { escaped = false; continue; } + if (ch == '\\' && in_string) { escaped = true; continue; } + if (ch == '"') { in_string = !in_string; continue; } + if (!in_string) { + if (ch == '{') depth++; + else if (ch == '}' && --depth == 0) { end_pos = c + 1; break; } + } + } + if (end_pos > 0) { + function_calls.push_back(json_content.substr(0, end_pos)); + } + } + } + + regular_response.erase(qwen_start_pos, erase_end - qwen_start_pos); + } + + const std::string TOOL_CALL_START = "<|tool_call_start|>"; + const std::string TOOL_CALL_END = "<|tool_call_end|>"; + size_t lfm2_start_pos = 0; + + while ((lfm2_start_pos = regular_response.find(TOOL_CALL_START, lfm2_start_pos)) != std::string::npos) { + size_t content_start = lfm2_start_pos + TOOL_CALL_START.length(); + size_t tool_end_pos = regular_response.find(TOOL_CALL_END, content_start); + + if (tool_end_pos != std::string::npos) { + std::string tool_content = regular_response.substr(content_start, tool_end_pos - content_start); + std::string content = tool_content; + size_t trim_start = 0; + while (trim_start < content.size() && std::isspace(static_cast(content[trim_start]))) { + trim_start++; + } + + if (trim_start < content.size()) { + size_t trim_end = content.size() - 1; + while (trim_end > trim_start && std::isspace(static_cast(content[trim_end]))) { + trim_end--; + } + content = content.substr(trim_start, trim_end - trim_start + 1); + } else { + content.clear(); + } + + if (!content.empty() && content.front() == '[' && content.back() == ']') { + std::string inner = content.substr(1, content.size() - 2); + + size_t inner_first = inner.find_first_not_of(" \t\n\r"); + if (inner_first != std::string::npos && inner[inner_first] == '{') { + size_t pos = inner_first; + while (pos < inner.size()) { + if (inner[pos] == '{') { + int brace_depth = 1; + size_t obj_start = pos; + pos++; + while (pos < inner.size() && brace_depth > 0) { + if (inner[pos] == '{') brace_depth++; + else if (inner[pos] == '}') brace_depth--; + pos++; + } + if (brace_depth == 0) { + std::string json_obj = inner.substr(obj_start, pos - obj_start); + if (json_obj.find("\"name\"") != std::string::npos) { + function_calls.push_back(json_obj); + } + } + } else { + pos++; + } + } + } else { + size_t start = 0; + int paren_depth = 0; + + for (size_t i = 0; i < inner.size(); ++i) { + char c = inner[i]; + if (c == '(') { + paren_depth++; + } else if (c == ')' && paren_depth > 0) { + paren_depth--; + } else if (c == ',' && paren_depth == 0) { + append_lfm2_call(inner.substr(start, i - start), function_calls); + start = i + 1; + } + } + + if (start < inner.size()) { + append_lfm2_call(inner.substr(start), function_calls); + } + } + } else if (!content.empty()) { + append_lfm2_call(content, function_calls); + } + + regular_response.erase(lfm2_start_pos, tool_end_pos + TOOL_CALL_END.length() - lfm2_start_pos); + } else { + break; + } + } + + const char* FUNCTION_CALL_MARKER = "\"function_call\""; + size_t search_pos = 0; + const size_t text_len = regular_response.length(); + + while (search_pos < text_len) { + size_t marker_pos = regular_response.find(FUNCTION_CALL_MARKER, search_pos); + if (marker_pos == std::string::npos) break; + + size_t json_start = regular_response.find('{', marker_pos); + if (json_start == std::string::npos) break; + + int brace_count = 1; + size_t json_end = json_start + 1; + while (json_end < text_len && brace_count > 0) { + char c = regular_response[json_end]; + brace_count += (c == '{') - (c == '}'); + json_end++; + } + + if (brace_count == 0) { + function_calls.push_back(regular_response.substr(json_start, json_end - json_start)); + regular_response = regular_response.substr(0, marker_pos); + size_t last_bracket = regular_response.rfind('{'); + if(last_bracket != std::string::npos) { + regular_response = regular_response.substr(0, last_bracket); + } + } + search_pos = json_end; + } +} + +inline std::vector> find_channel_token_ranges( + const std::vector& tokens, size_t offset, + uint32_t channel_open_id, uint32_t channel_close_id) { + std::vector> ranges; + size_t pos = 0; + while (pos < tokens.size()) { + if (tokens[pos] != channel_open_id) { + pos++; + continue; + } + + size_t block_start = pos; + pos++; + while (pos < tokens.size() && tokens[pos] != channel_close_id) { + pos++; + } + if (pos < tokens.size()) { + pos++; + } + ranges.push_back({offset + block_start, pos - block_start}); + } + return ranges; +} + +inline void strip_tag_blocks(std::string& text, std::string& extracted, + const std::string& open_tag, const std::string& close_tag) { + std::string result; + size_t pos = 0; + + size_t first_close = text.find(close_tag); + size_t first_open = text.find(open_tag); + if (first_close != std::string::npos && + (first_open == std::string::npos || first_close < first_open)) { + extracted += text.substr(0, first_close); + pos = first_close + close_tag.size(); + } + + while (pos < text.size()) { + size_t open_pos = text.find(open_tag, pos); + if (open_pos == std::string::npos) { + result += text.substr(pos); + break; + } + result += text.substr(pos, open_pos - pos); + size_t content_start = open_pos + open_tag.size(); + size_t close_pos = text.find(close_tag, content_start); + if (close_pos == std::string::npos) { + if (!extracted.empty()) extracted += "\n"; + extracted += text.substr(content_start); + break; + } + if (!extracted.empty()) extracted += "\n"; + extracted += text.substr(content_start, close_pos - content_start); + pos = close_pos + close_tag.size(); + } + text = result; +} + +inline void strip_thinking_block(const std::string& input, std::string& thinking, std::string& content) { + thinking.clear(); + content = input; + + auto trim = [](std::string& s) { + size_t first = s.find_first_not_of(" \t\n\r"); + size_t last = s.find_last_not_of(" \t\n\r"); + if (first != std::string::npos && last != std::string::npos) + s = s.substr(first, last - first + 1); + else + s.clear(); + }; + + if (content.find("<|channel>") != std::string::npos || content.find("") != std::string::npos) { + strip_tag_blocks(content, thinking, "<|channel>", ""); + } else if (content.find("") != std::string::npos || content.find("") != std::string::npos) { + strip_tag_blocks(content, thinking, "", ""); + } else { + return; + } + + trim(thinking); + trim(content); +} + +struct TranscriptSegment { + float start; + float end; + std::string text; +}; + +inline std::string construct_response_json(const std::string& regular_response, + const std::vector& function_calls, + double time_to_first_token, + double total_time_ms, + double prefill_tps, + double decode_tps, + size_t prompt_tokens, + size_t completion_tokens, + float confidence = 0.0f, + bool cloud_handoff = false, + const std::string& thinking = "", + const std::vector& segments = {}) { + std::ostringstream json; + json << "{"; + json << "\"success\":true,"; + json << "\"error\":null,"; + json << "\"cloud_handoff\":" << (cloud_handoff ? "true" : "false") << ","; + json << "\"response\":\"" << escape_json_string(regular_response) << "\","; + if (!thinking.empty()) { + json << "\"thinking\":\"" << escape_json_string(thinking) << "\","; + } + json << "\"function_calls\":["; + for (size_t i = 0; i < function_calls.size(); ++i) { + if (i > 0) json << ","; + json << function_calls[i]; + } + json << "],"; + json << "\"segments\":["; + for (size_t i = 0; i < segments.size(); ++i) { + if (i > 0) json << ","; + json << "{\"start\":" << std::fixed << std::setprecision(3) << segments[i].start + << ",\"end\":" << std::fixed << std::setprecision(3) << segments[i].end + << ",\"text\":\"" << escape_json_string(segments[i].text) << "\"}"; + } + json << "],"; + json << "\"confidence\":" << std::fixed << std::setprecision(4) << confidence << ","; + json << "\"time_to_first_token_ms\":" << std::fixed << std::setprecision(2) << time_to_first_token << ","; + json << "\"total_time_ms\":" << std::fixed << std::setprecision(2) << total_time_ms << ","; + json << "\"prefill_tps\":" << std::fixed << std::setprecision(2) << prefill_tps << ","; + json << "\"decode_tps\":" << std::fixed << std::setprecision(2) << decode_tps << ","; + json << "\"ram_usage_mb\":" << std::fixed << std::setprecision(2) << get_ram_usage_mb() << ","; + json << "\"prefill_tokens\":" << prompt_tokens << ","; + json << "\"decode_tokens\":" << completion_tokens << ","; + json << "\"total_tokens\":" << (prompt_tokens + completion_tokens); + json << "}"; + return json.str(); +} + +inline std::string serialize_function_calls(const std::vector& calls) { + if (calls.empty()) return "[]"; + std::ostringstream oss; + oss << "["; + for (size_t i = 0; i < calls.size(); ++i) { + if (i > 0) oss << ","; + oss << calls[i]; + } + oss << "]"; + return oss.str(); +} + +inline int validate_audio_params( + const char* component, + void* model, + char* response_buffer, size_t buffer_size, + const char* audio_file_path, + const uint8_t* pcm_buffer, size_t pcm_buffer_size) { + if (!model) { + std::string err = last_error_message.empty() ? "Model not initialized." : last_error_message; + CACTUS_LOG_ERROR(component, err); + handle_error_response(err, response_buffer, buffer_size); + return -1; + } + if (!response_buffer || buffer_size == 0) { + CACTUS_LOG_ERROR(component, "Invalid parameters: response_buffer or buffer_size"); + handle_error_response("Invalid parameters", response_buffer, buffer_size); + return -1; + } + if (!audio_file_path && (!pcm_buffer || pcm_buffer_size == 0)) { + CACTUS_LOG_ERROR(component, "No audio input provided"); + handle_error_response("Either audio_file_path or pcm_buffer must be provided", response_buffer, buffer_size); + return -1; + } + if (audio_file_path && pcm_buffer && pcm_buffer_size > 0) { + CACTUS_LOG_ERROR(component, "Both audio_file_path and pcm_buffer provided"); + handle_error_response("Cannot provide both audio_file_path and pcm_buffer", response_buffer, buffer_size); + return -1; + } + if (pcm_buffer && pcm_buffer_size > 0 && (pcm_buffer_size < 2 || pcm_buffer_size % 2 != 0)) { + CACTUS_LOG_ERROR(component, "Invalid pcm_buffer_size"); + handle_error_response("pcm_buffer_size must be even and at least 2 bytes", response_buffer, buffer_size); + return -1; + } + return 0; +} + +inline std::vector pcm_to_float(const uint8_t* pcm_buffer, size_t pcm_buffer_size) { + const int16_t* samples = reinterpret_cast(pcm_buffer); + size_t n = pcm_buffer_size / 2; + std::vector out(n); + for (size_t i = 0; i < n; ++i) + out[i] = static_cast(samples[i]) / 32768.0f; + return out; +} + +} // namespace ffi +} // namespace cactus + +#ifdef __cplusplus +extern "C" { +#endif + +const char* cactus_get_last_error(); + +#ifdef __cplusplus +} +#endif + +#endif // CACTUS_UTILS_H diff --git a/Frameworks/cactus-ios.xcframework/ios-arm64-simulator/cactus.framework/Headers/engine.h b/Frameworks/cactus-ios.xcframework/ios-arm64-simulator/cactus.framework/Headers/engine.h new file mode 100644 index 00000000..8b9d3f6c --- /dev/null +++ b/Frameworks/cactus-ios.xcframework/ios-arm64-simulator/cactus.framework/Headers/engine.h @@ -0,0 +1,1079 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "../graph/graph.h" + +#ifdef __clang__ +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wc99-extensions" +#pragma clang diagnostic ignored "-Wunused-parameter" +#elif defined(__GNUC__) +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wpedantic" +#pragma GCC diagnostic ignored "-Wunused-parameter" +#endif + +extern "C" { + #include "../../libs/stb/stb_image.h" + #include "../../libs/stb/stb_image_resize2.h" +} + +#ifdef __clang__ +#pragma clang diagnostic pop +#elif defined(__GNUC__) +#pragma GCC diagnostic pop +#endif + +class CactusGraph; + +namespace cactus { +namespace npu { + class NPUPrefill; +} +namespace engine { + +class Siglip2Preprocessor; + +struct Config { + uint32_t vocab_size = 151936; + uint32_t bos_token_id = 151643; + uint32_t eos_token_id = 151645; + uint32_t num_layers = 28; + uint32_t hidden_dim = 1024; + uint32_t ffn_intermediate_dim = 3072; + uint32_t attention_heads = 16; + uint32_t attention_kv_heads = 8; + uint32_t attention_head_dim = 128; + float layer_norm_eps = 1e-6f; + float rope_theta = 1000000.0f; + uint32_t num_experts = 0; + uint32_t num_shared_experts = 0; + uint32_t num_top_experts = 0; + uint32_t moe_every_n_layers = 0; + uint32_t moe_intermediate_dim = 0; + uint32_t num_dense_layers = 0; + uint32_t num_experts_per_tok = 0; + bool norm_topk_prob = false; + bool use_expert_bias = false; + float routed_scaling_factor = 1.0f; + bool tie_word_embeddings = true; + + uint32_t vision_hidden_dim = 0; + uint32_t vision_num_layers = 0; + uint32_t vision_attention_heads = 0; + uint32_t vision_image_size = 0; + uint32_t vision_patch_size = 0; + uint32_t vision_num_channels = 3; + uint32_t vision_embed_dim = 0; + uint32_t visual_tokens_per_img = 0; + bool use_pixel_shuffle = false; + uint32_t pixel_shuffle_factor = 1; + bool use_image_tokens = false; + uint32_t image_token_id = 0; + bool use_layout_tags = false; + uint32_t image_seq_len = 64; + + uint32_t global_image_size = 2048; + uint32_t max_tile_size = 512; + float rescale_factor = 0.00392156862745098f; + float image_mean = 0.5f; + float image_std = 0.5f; + + uint32_t downsample_factor = 2; + uint32_t min_tiles = 2; + uint32_t max_tiles = 10; + bool use_thumbnail = true; + uint32_t min_image_tokens = 64; + uint32_t max_image_tokens = 256; + uint32_t max_num_patches = 1024; + uint32_t tile_size = 512; + float max_pixels_tolerance = 2.0f; + bool do_image_splitting = true; + bool encoder_act_gelu = false; + bool decoder_act_gelu = false; + uint32_t num_encoder_layers = 0; + uint32_t num_decoder_layers = 0; + float partial_rotary_factor = 0.0f; + uint32_t pad_token_id = 0; + uint32_t conv_kernel_size = 0; + uint32_t subsampling_conv_kernel_size = 0; + uint32_t subsampling_conv_stride = 0; + uint32_t subsampling_conv_channels = 0; + uint32_t subsampling_factor = 0; + uint32_t num_mel_bins = 80; + std::string encoder_hidden_act = "silu"; + uint32_t linear_num_key_heads = 0; + uint32_t linear_key_head_dim = 0; + uint32_t linear_num_value_heads = 0; + uint32_t linear_value_head_dim = 0; + uint32_t linear_q_proj_dim = 0; + uint32_t linear_k_proj_dim = 0; + uint32_t linear_v_proj_dim = 0; + + uint32_t kv_lora_rank = 0; + uint32_t q_lora_rank = 0; + uint32_t qk_head_dim = 0; + uint32_t qk_nope_head_dim = 0; + uint32_t qk_rope_head_dim = 0; + uint32_t v_head_dim = 0; + uint32_t rope_interleave = 0; + bool attention_bias = false; + float rope_scaling_factor = 1.0f; + float rope_mscale_all_dim = 0.0f; + + enum class ModelType {QWEN = 0, GEMMA = 1, NOMIC = 3, LFM2 = 5, SIGLIP2 = 6, WHISPER = 7, MOONSHINE = 8, SILERO_VAD = 9, PARAKEET = 10, QWEN3P5 = 11, PARAKEET_TDT = 12, GEMMA3N = 13, YOUTU = 14, GEMMA4 = 15, PYANNOTE = 16, WESPEAKER = 17, NEEDLE = 18}; + uint32_t predictor_hidden_dim = 0; + uint32_t predictor_num_layers = 0; + uint32_t tdt_joint_dim = 0; + uint32_t tdt_num_durations = 0; + uint32_t tdt_blank_id = 0; + std::vector tdt_durations; + + ModelType model_type = ModelType::QWEN; + + enum class ModelVariant {DEFAULT = 0, VLM = 1, EXTRACT = 2, RAG = 3}; + ModelVariant model_variant = ModelVariant::DEFAULT; + + enum class Activation {GELU = 0, SILU = 1}; + Activation activation = Activation::SILU; + + enum class Backend {CPU = 0, NPU = 1}; + Backend default_backend = Backend::CPU; + + enum class Precision {INT8 = 0, FP16 = 1, FP32 = 2}; + Precision precision = Precision::FP32; + + float default_temperature = 0.6f; + float default_top_p = 0.95f; + size_t default_top_k = 20; + float default_max_tps = -1.0f; + float default_cloud_handoff_threshold = 0.0f; + + std::vector layer_types; + size_t conv_L_cache = 0; + + uint32_t altup_num_inputs = 4; + uint32_t laurel_rank = 64; + uint32_t hidden_size_per_layer_input = 256; + uint32_t num_kv_shared_layers = 0; + uint32_t sliding_window = 512; + float rope_local_base_freq = 10000.0f; + float final_logit_softcapping = 0.0f; + float global_partial_rotary_factor = 1.0f; + uint32_t expert_intermediate_size = 0; + uint32_t global_head_dim = 0; + uint32_t num_global_kv_heads = 0; + bool attention_k_eq_v = false; + bool enable_moe_block = false; + std::vector activation_sparsity_ppf; + + uint32_t vision_head_dim = 64; + uint32_t vision_kv_heads = 12; + uint32_t vision_intermediate_size = 3072; + uint32_t vision_position_embedding_size = 10240; + uint32_t vision_pooling_kernel_size = 3; + uint32_t vision_default_output_length = 280; + float vision_rope_theta = 100.0f; + + uint32_t audio_hidden_dim = 0; + uint32_t audio_num_layers = 0; + uint32_t audio_num_heads = 0; + uint32_t audio_head_dim = 0; + uint32_t audio_input_feat_size = 128; + uint32_t audio_conf_conv_kernel_size = 5; + uint32_t audio_chunk_size = 12; + uint32_t audio_context_left = 13; + uint32_t audio_context_right = 0; + float audio_logit_cap = 50.0f; + float audio_residual_weight = 0.5f; + uint32_t audio_output_proj_dims = 0; + uint32_t audio_vocab_size = 128; + uint32_t audio_vocab_offset = 0; + uint32_t audio_soft_tokens = 188; + uint32_t audio_sscp_conv0_channels = 128; + uint32_t audio_sscp_conv1_channels = 32; + float audio_sscp_conv_eps = 1e-3f; + float audio_rms_norm_eps = 1e-6f; + uint32_t audio_fft_length = 1024; + uint32_t audio_token_id = 0; + bool audio_fft_overdrive = false; + uint32_t channel_open_token_id = 100; + uint32_t channel_close_token_id = 101; + + static bool is_gemma_family(ModelType t) { + return t == ModelType::GEMMA || t == ModelType::GEMMA3N || t == ModelType::GEMMA4; + } + + bool from_json(const std::string& json_path); + std::string to_json() const; +}; + + + +struct MergeRule { + std::string first; + std::string second; + std::string merged; + uint32_t priority; + + MergeRule(const std::string& f, const std::string& s, const std::string& m, uint32_t p) + : first(f), second(s), merged(m), priority(p) {} +}; + + +struct ToolCallInfo { + std::string name; + std::string arguments; +}; + +struct ChatMessage { + std::string role; + std::string content; + std::string name; + std::vector images; + std::vector audio; + size_t audio_soft_token_count = 0; + std::vector tool_calls; +}; + +inline std::string format_needle_query_text(const std::vector& messages) { + std::string system_text; + std::string user_query; + + for (const auto& msg : messages) { + if (msg.role == "system") { + if (!system_text.empty()) { + system_text += "\n"; + } + system_text += msg.content; + } else if (msg.role == "user") { + user_query = msg.content; + } + } + + if (user_query.empty() && !messages.empty()) { + user_query = messages.back().content; + } + if (system_text.empty()) { + return user_query; + } + if (user_query.empty()) { + return system_text; + } + return system_text + "\n\n" + user_query; +} + +struct ToolConstraintSpec { + std::string name; + std::vector parameter_names; + std::vector required_parameter_names; +}; + +struct TokenizerRuntimeConfig { + enum class TokenizerType { UNKNOWN, BPE, SENTENCEPIECE }; + enum class VocabFormat { UNKNOWN, ID_TAB_TOKEN, LINE_TOKEN }; + enum class Normalizer { NONE, METASPACE, BYTE_LEVEL }; + enum class Decoder { NONE, REPLACE_METASPACE, BYTE_LEVEL }; + + TokenizerType tokenizer_type = TokenizerType::UNKNOWN; + VocabFormat vocab_format = VocabFormat::UNKNOWN; + Normalizer normalizer = Normalizer::NONE; + Decoder decoder = Decoder::NONE; + bool byte_fallback = false; + bool has_chat_template = false; +}; + +TokenizerRuntimeConfig load_tokenizer_runtime_config(const std::string& config_file); +void load_special_tokens_map(const std::string& config_file, std::unordered_map& special_tokens); +std::vector split_with_special_tokens(const std::string& text, const std::unordered_map& special_tokens); + +class Tokenizer { +public: + virtual ~Tokenizer() = default; + + virtual std::vector encode(const std::string& text) const = 0; + virtual std::string decode(const std::vector& tokens) const = 0; + + virtual std::vector apply_chat_template(const std::vector& messages, bool add_generation_prompt = true) const; + virtual std::string format_chat_prompt(const std::vector& messages, bool add_generation_prompt = true, const std::string& tools_json = "", bool enable_thinking_if_supported = false) const; + + virtual uint32_t get_vocab_size() const = 0; + virtual uint32_t get_unk_token() const = 0; + virtual uint32_t get_bos_token() const = 0; + virtual uint32_t get_eos_token() const = 0; + virtual bool has_chat_template() const { return has_chat_template_; } + std::string get_default_stop_sequence() const; + + virtual bool load_vocabulary_with_config(const std::string& vocab_file, const std::string& merges_file, const std::string& config_file) = 0; + + uint32_t get_image_token_id() const { return image_token_id_; } + uint32_t get_fake_token_id() const { return fake_token_id_; } + uint32_t get_global_img_token_id() const { return global_img_token_id_; } + +protected: + enum class ModelType { UNKNOWN, QWEN, QWEN3P5, GEMMA, GEMMA4, LFM2, BERT, WHISPER, PARAKEET, YOUTU, NEEDLE}; + ModelType model_type_ = ModelType::UNKNOWN; + enum class ModelVariant { DEFAULT, VLM, EXTRACT, RAG}; + ModelVariant model_variant_ = ModelVariant::DEFAULT; + bool has_chat_template_ = false; + std::string chat_template_; + + uint32_t image_token_id_ = 396; + uint32_t fake_token_id_ = 49189; + uint32_t global_img_token_id_ = 49152; + + + uint32_t vision_patch_size_ = 16; + uint32_t vision_pooling_kernel_size_ = 3; + uint32_t vision_default_output_length_ = 280; + uint32_t vision_image_size_ = 768; + TokenizerRuntimeConfig runtime_config_; + + void detect_model_type(const std::string& config_path); + void load_chat_template(const std::string& template_file); + std::string format_qwen_style(const std::vector& messages, bool add_generation_prompt, const std::string& tools_json, bool enable_thinking_if_supported = false) const; + std::string format_gemma_style(const std::vector& messages, bool add_generation_prompt, const std::string& tools_json) const; + std::string format_gemma4_style(const std::vector& messages, bool add_generation_prompt, const std::string& tools_json, bool enable_thinking_if_supported = false) const; + std::string format_lfm2_style(const std::vector& messages, bool add_generation_prompt, const std::string& tools_json) const; + std::string format_lfm2_vl_style(const std::vector& messages, bool add_generation_prompt, const std::string& tools_json) const; + std::string format_needle_style(const std::vector& messages, bool add_generation_prompt, const std::string& tools_json) const; + std::string format_youtu_style(const std::vector& messages, bool add_generation_prompt, const std::string& tools_json) const; +}; + +class BPETokenizer : public Tokenizer { +public: + BPETokenizer(); + ~BPETokenizer(); + + bool load_vocabulary_mmap(const std::string& vocab_file, const std::string& merges_file); + bool load_vocabulary_with_config(const std::string& vocab_file, const std::string& merges_file, const std::string& config_file) override; + + std::vector encode(const std::string& text) const override; + std::string decode(const std::vector& tokens) const override; + + uint32_t get_vocab_size() const override { return vocab_size_; } + uint32_t get_unk_token() const override { return unk_token_id_; } + uint32_t get_bos_token() const override { return bos_token_id_; } + uint32_t get_eos_token() const override { return eos_token_id_; } + +private: + std::unordered_map token_to_id_; + std::vector id_to_token_; + std::vector merge_rules_; + std::unordered_map merge_map_; + + uint32_t vocab_size_; + uint32_t unk_token_id_; + uint32_t bos_token_id_; + uint32_t eos_token_id_; + + void* vocab_mmap_ptr_; + size_t vocab_mmap_size_; + + void* merges_mmap_ptr_; + size_t merges_mmap_size_; + + std::vector apply_bpe(const std::vector& tokens) const; + std::pair find_best_merge_fast(const std::vector& tokens) const; + + std::string bytes_to_unicode(const std::string& text) const; + std::string unicode_to_bytes(const std::string& text) const; + std::vector byte_level_split(const std::string& text) const; + std::vector utf8_split(const std::string& text) const; + + void cleanup_mmap(); + +private: + mutable std::unordered_map byte_to_unicode_; + mutable std::unordered_map unicode_to_byte_; + void init_byte_mappings() const; + + std::unordered_map special_tokens_; + std::vector split_with_special_tokens(const std::string& text) const; + void load_special_tokens(const std::string& config_file); +}; + +class SPTokenizer : public Tokenizer { +public: + SPTokenizer(); + ~SPTokenizer(); + + bool load_vocabulary_with_config(const std::string& vocab_file, const std::string& merges_file, const std::string& config_file) override; + + std::vector encode(const std::string& text) const override; + std::string decode(const std::vector& tokens) const override; + + uint32_t get_vocab_size() const override { return vocab_size_; } + uint32_t get_unk_token() const override { return unk_token_id_; } + uint32_t get_bos_token() const override { return bos_token_id_; } + uint32_t get_eos_token() const override { return eos_token_id_; } + +private: + struct TrieNode { + std::unordered_map> children; + int32_t token_id = -1; + float score = 0.0f; + }; + + std::unique_ptr trie_root_; + std::unordered_map token_to_id_; + std::vector id_to_token_; + std::vector token_scores_; + + uint32_t vocab_size_; + uint32_t unk_token_id_; + uint32_t bos_token_id_; + uint32_t eos_token_id_; + uint32_t pad_token_id_; + + bool sp_bpe_mode_ = false; + bool sp_add_dummy_prefix_ = false; + bool sp_byte_fallback_ = false; + + void* vocab_mmap_ptr_; + size_t vocab_mmap_size_; + + void build_trie(); + std::vector> tokenize_with_trie(const std::string& text) const; + std::vector tokenize_with_bpe(const std::string& text) const; + std::string preprocess_text(const std::string& text) const; + std::string postprocess_text(const std::string& text) const; + std::vector split_by_unicode_spaces(const std::string& text) const; + + void cleanup_mmap(); + + std::unordered_map special_tokens_; + std::vector split_with_special_tokens(const std::string& text) const; + void load_special_tokens(const std::string& config_file); +}; + +class ConvCache { +public: + struct CircularView { + const void* ptr1; + size_t len1; + const void* ptr2; + size_t len2; + size_t total_len; + }; + + void init(size_t layers, size_t hidden_dim, size_t window_len, Precision model_precision); + CircularView get_window(size_t layer) const; + void update(CactusGraph* gb, size_t layer, const size_t latest_token); + void reset(); + + bool is_empty() const { return num_layers == 0; } + + size_t num_layers = 0; + size_t hidden_size = 0; + size_t window_size = 0; + Precision precision = Precision::FP32; + size_t element_size = 4; + +private: + struct LayerState { + std::vector data; + size_t head = 0; + size_t count = 0; + }; + + std::vector layer_states; +}; + +struct KVCache { + static constexpr size_t DEFAULT_WINDOW_SIZE = 1024; + static constexpr size_t DEFAULT_SINK_SIZE = 4; + + struct LayerCache { + std::vector keys; + std::vector values; + std::vector key_scales; + std::vector value_scales; + size_t head_dim = 0; + size_t kv_heads = 0; + }; + + std::vector layer_caches; + + size_t window_size = DEFAULT_WINDOW_SIZE; + size_t sink_size = DEFAULT_SINK_SIZE; + size_t current_seq_len = 0; + size_t total_seq_len = 0; + size_t max_seq_len = 2048; + size_t num_layers = 0; + Precision precision; + size_t element_size = 4; + + void set_window_size(size_t window, size_t sink = DEFAULT_SINK_SIZE); + size_t get_effective_seq_len() const { return current_seq_len; } + size_t get_total_seq_len() const { return total_seq_len; } + size_t get_layer_head_dim(size_t layer_idx) const { return layer_caches[layer_idx].head_dim; } + size_t get_layer_kv_heads(size_t layer_idx) const { return layer_caches[layer_idx].kv_heads; } + + void init(size_t num_layers, size_t max_seq, const std::vector& layer_dims, const std::vector& layer_kv_heads, Precision model_precision); + void reset(); + void update_from_graph(CactusGraph* gb, const std::vector& k_nodes, + const std::vector& v_nodes, size_t seq_len, + size_t num_layers); + + void update_from_npu(size_t layer_idx, const __fp16* k_data, const __fp16* v_data, + size_t num_tokens, size_t kv_heads, size_t head_dim); + + bool is_empty() const { return current_seq_len == 0; } + void* get_key_ptr(size_t layer); + void* get_value_ptr(size_t layer); + + struct CircularView { + const void* ptr1; + const void* ptr2; + size_t len1; + size_t len2; + size_t total_len; + }; + + CircularView get_key_view(size_t layer); + CircularView get_value_view(size_t layer); + + const int8_t* get_keys_int8(size_t layer) const; + const int8_t* get_values_int8(size_t layer) const; + const float* get_key_scales(size_t layer) const; + const float* get_value_scales(size_t layer) const; + + void remove_token_range(size_t start, size_t count); + void compact_to_windows(const std::vector& target_windows); +}; + +class ToolCallConstrainer { +public: + enum class State { + DONE, + + QWEN_START, + QWEN_EXPECT_OPEN_BRACE, + QWEN_EXPECT_NAME_KEY, + QWEN_EXPECT_NAME_COLON, + QWEN_EXPECT_NAME_VALUE, + QWEN_EXPECT_COMMA, + QWEN_EXPECT_ARGS_KEY, + QWEN_EXPECT_ARGS_COLON, + QWEN_IN_ARGUMENTS, + QWEN_EXPECT_CLOSE_BRACE, + QWEN_EXPECT_END, + + NEEDLE_START, + + LFM_START, + LFM_EXPECT_BRACKET, + LFM_IN_FUNC_NAME, + LFM_EXPECT_PAREN, + LFM_IN_ARGUMENTS, + LFM_EXPECT_BRACKET_CLOSE, + LFM_EXPECT_END, + + GEMMA_START, + GEMMA_EXPECT_CALL, + GEMMA_IN_FUNC_NAME, + GEMMA_EXPECT_BRACE, + GEMMA_IN_ARGUMENTS, + GEMMA_EXPECT_END + }; + + void init(Config::ModelType model_type, + const std::vector& tools, + Tokenizer* tokenizer); + + const std::unordered_map& get_bias() const { return current_bias_; } + + void update(uint32_t token_id, const std::string& decoded_text); + + void reset(); + + bool is_active() const { return active_; } + +private: + bool active_ = false; + State state_ = State::QWEN_START; + Config::ModelType model_type_ = Config::ModelType::QWEN; + Tokenizer* tokenizer_ = nullptr; + + bool is_gemma_family() const { return Config::is_gemma_family(model_type_); } + bool is_needle() const { return model_type_ == Config::ModelType::NEEDLE; } + + enum class NeedleJsonState { + FREE, + IN_NAME, + IN_ARG_KEY, + }; + + struct NeedleTrieNode { + std::unordered_map> children; + bool is_terminal = false; + }; + + std::vector tool_specs_; + std::vector function_names_; + std::string generated_text_; + int brace_depth_ = 0; + + std::string call_start_tag_; + std::string call_end_tag_; + + std::unordered_set qwen_tool_call_start_tokens_; + std::unordered_set qwen_tool_call_end_tokens_; + std::unordered_set open_brace_tokens_; + std::unordered_set close_brace_tokens_; + std::unordered_set colon_tokens_; + std::unordered_set comma_tokens_; + std::unordered_set name_key_tokens_; + std::unordered_set args_key_tokens_; + std::unordered_set quote_tokens_; + std::unordered_set backtick_tokens_; + std::unordered_set all_func_name_tokens_; + std::unordered_map> func_name_sequences_; + NeedleJsonState needle_json_state_ = NeedleJsonState::FREE; + std::string needle_buffer_; + std::string needle_constrained_buf_; + std::string needle_current_function_; + bool needle_in_arguments_ = false; + int needle_arguments_depth_ = 0; + int needle_nesting_depth_ = 0; + bool needle_in_string_value_ = false; + bool needle_between_pairs_ = false; + bool needle_prev_char_escape_ = false; + std::unique_ptr needle_name_trie_; + std::unordered_map> needle_param_tries_; + std::unordered_map> needle_required_params_; + std::unordered_set needle_seen_arg_keys_; + std::vector needle_token_strings_; + std::unordered_map> needle_token_index_; + std::unordered_set needle_arg_close_tokens_; + + std::unordered_set tool_start_tokens_; + std::unordered_set tool_end_tokens_; + std::unordered_set bracket_open_tokens_; + std::unordered_set bracket_close_tokens_; + std::unordered_set paren_open_tokens_; + std::unordered_set paren_close_tokens_; + std::unordered_set equals_tokens_; + + std::unordered_set gemma_call_start_tokens_; + std::unordered_set gemma_call_end_tokens_; + std::unordered_set gemma_response_start_tokens_; + std::unordered_set gemma_call_prefix_tokens_; + std::unordered_set escape_tokens_; + + std::unordered_map current_bias_; + + void compute_bias(); + void tokenize_grammar_elements(); + void add_tokens_for_string(const std::string& str, std::unordered_set& token_set); + void tokenize_function_names(bool quote_names); + void init_common_tokens(); + void init_needle_constraints(); + void reset_needle_constraints(); + void feed_needle_text(const std::string& text); + void feed_needle_char(char ch); + bool needle_at_arg_key_start() const; + bool needle_is_value_string_start() const; + void needle_insert_word(NeedleTrieNode* root, const std::string& word); + const NeedleTrieNode* needle_get_trie_node(const NeedleTrieNode* root, const std::string& prefix) const; + bool needle_check_token_valid(const std::string& token_text, const NeedleTrieNode* trie_node) const; + bool needle_has_unseen_completion(const NeedleTrieNode* node, std::string& partial) const; +}; + +class Model { +public: + struct DebugNode { + uint32_t layer_idx; + std::string name; + size_t node_id; + }; + + Model(); + explicit Model(const Config& config); + virtual ~Model(); + + const Config& get_config() const { return config_; } + Tokenizer* get_tokenizer() const { return tokenizer_.get(); } + const std::vector& get_debug_nodes() const; + + virtual bool init(const std::string& model_folder, size_t context_size, const std::string& system_prompt = "", bool do_warmup = true); + + virtual bool init(CactusGraph* external_graph, const std::string& model_folder, size_t context_size, + const std::string& system_prompt = "", bool do_warmup = true); + + virtual uint32_t decode(const std::vector& tokens, float temperature = -1.0f, float top_p = -1.0f, + size_t top_k = 0, const std::string& profile_file = "", float* out_entropy = nullptr, + float min_p = 0.15f, float repetition_penalty = 1.1f); + + virtual void prefill(const std::vector& tokens, size_t chunk_size = 256, const std::string& profile_file = ""); + + virtual void prefill_with_images(const std::vector& tokens, const std::vector& image_paths, + const std::string& profile_file = ""); + + virtual uint32_t decode_with_images(const std::vector& tokens, const std::vector& image_paths, + float temperature = -1.0f, float top_p = -1.0f, + size_t top_k = 0, const std::string& profile_file = "", float* out_entropy = nullptr, + float min_p = 0.15f, float repetition_penalty = 1.1f); + + virtual uint32_t decode_with_audio(const std::vector& tokens, const std::vector& audio_features, float temperature = 0.0f, float top_p = 0.0f, + size_t top_k = 0, const std::string& profile_file = "", float* out_entropy = nullptr, + float min_p = 0.15f, float repetition_penalty = 1.1f, + float* out_token_time_start = nullptr, float* out_token_time_end = nullptr); + + std::vector get_embeddings(const std::vector& tokens, bool pooled = true, bool normalize = false, const std::string& profile_file = ""); + + virtual std::vector get_image_embeddings(const std::string& image_path); + + virtual std::vector get_audio_embeddings(const std::vector& audio_features); + + virtual void reset_cache() { kv_cache_.reset(); token_history_.clear(); } + void record_sampled_token(uint32_t token) { + if (token_history_.size() >= MAX_TOKEN_HISTORY) { + token_history_.erase(token_history_.begin(), token_history_.begin() + (MAX_TOKEN_HISTORY / 2)); + } + token_history_.push_back(token); + } + + double score_tokens_window_logprob(const std::vector& tokens, size_t start, size_t end, size_t context, size_t* tokens_scored); + + + + void set_cache_window(size_t window_size, size_t sink_size = 4) { kv_cache_.set_window_size(window_size, sink_size); } + + bool load_npu_prefill(const std::string& model_path); + bool has_npu_prefill() const; + size_t get_prefill_chunk_size() const; + + virtual void remove_thinking_tokens(const std::vector>& ranges); + virtual void compact_kv_cache() {} + + void set_tool_constraints(const std::vector& tools); + void clear_tool_constraints(); + void update_tool_constraints(uint32_t token_id); + + void* graph_handle_; + + void set_vocab_bias(const std::unordered_map& bias) { + vocab_bias_ = bias; + } + + void clear_vocab_bias() { + vocab_bias_.clear(); + } + + bool has_vocab_bias() const { + return !vocab_bias_.empty(); + } + + const std::unordered_map& get_vocab_bias() const { + return vocab_bias_; + } + +protected: + size_t sample_token(CactusGraph* gb, size_t logits_node_id, float temperature, float top_p, size_t top_k, + float min_p, float repetition_penalty, + const std::unordered_map* extra_bias = nullptr) const; + + static void compute_entropy(CactusGraph* gb, size_t logits_node_id, float* out_entropy); + + virtual size_t forward(const std::vector& tokens, bool use_cache = false) = 0; + + virtual size_t forward(const std::vector& audio_features, const std::vector& tokens, bool use_cache = false); + + virtual void load_weights_to_graph(CactusGraph* gb) = 0; + + virtual size_t build_attention(CactusGraph* gb, size_t normalized_input, uint32_t layer_idx, + ComputeBackend backend, bool use_cache = false, size_t position_offset = 0) = 0; + + virtual size_t build_mlp(CactusGraph* gb, size_t normalized_h, uint32_t layer_idx, + ComputeBackend backend) const = 0; + virtual size_t build_transformer_block(CactusGraph* gb, size_t hidden, uint32_t layer_idx, + ComputeBackend backend, bool use_cache = false, size_t position_offset = 0) = 0; + void update_kv_cache(CactusGraph* gb, size_t seq_len); + virtual std::vector get_kv_layer_dims() const { + return std::vector(config_.num_layers, config_.attention_head_dim); + } + virtual std::vector get_kv_layer_heads() const { + return std::vector(config_.num_layers, config_.attention_kv_heads); + } + virtual void post_init() {} + virtual void post_execute_updates(CactusGraph*, size_t) {} + Config config_; + std::unique_ptr tokenizer_; + + bool initialized_; + float attention_scale_; + +protected: + KVCache kv_cache_; + std::vector cache_k_output_nodes_; + std::vector cache_v_output_nodes_; + + std::string embedding_file_path_; + size_t embedding_node_id_; + std::string model_folder_path_; + size_t output_weight_node_id_; + + mutable std::vector debug_nodes_; + + void capture_debug_node(uint32_t layer_idx, const std::string& name, size_t node_id) const; + void clear_debug_nodes(); + + bool init_internal(CactusGraph* gb, const std::string& model_folder, size_t context_size, + const std::string& system_prompt, bool do_warmup); + bool owns_graph_; + + std::unique_ptr npu_prefill_; + void prefill_npu(const std::vector& tokens); + virtual std::vector<__fp16> get_token_embeddings(const std::vector& tokens); + + static constexpr size_t MAX_TOKEN_HISTORY = 128; + ToolCallConstrainer tool_constrainer_; + std::vector token_history_; + +private: + std::unordered_map vocab_bias_; +}; + +std::unique_ptr create_model(const std::string& model_folder); + +class Siglip2Preprocessor { +public: + struct Config { + int patch_size = 16; + int downsample_factor = 2; + int min_tiles = 2; + int max_tiles = 10; + bool use_thumbnail = true; + int min_image_tokens = 64; + int max_image_tokens = 256; + int max_num_patches = 1024; + int tile_size = 512; + float max_pixels_tolerance = 2.0f; + bool do_resize = true; + bool do_rescale = true; + bool do_normalize = true; + bool do_convert_rgb = true; + bool do_image_splitting = true; + float rescale_factor = 1.0f / 255.0f; + float image_mean[3] = {0.5f, 0.5f, 0.5f}; + float image_std[3] = {0.5f, 0.5f, 0.5f}; + }; + + struct PreprocessedImage { + std::vector pixel_values; + std::vector pixel_attention_mask; + std::vector> spatial_shapes; + std::vector pixel_values_shape; + std::vector pixel_attention_mask_shape; + std::vector spatial_shapes_shape; + int num_patches_height; + int num_patches_width; + int actual_num_patches; + int num_tiles; + int patch_dim; + int max_patches_per_tile; + + int image_rows; + int image_cols; + int image_height; + int image_width; + int tokens_per_tile; + int thumbnail_tokens; + + ~PreprocessedImage(); + }; + + struct SpatialShapeResult { + std::vector> shapes; + int grid_rows; + int grid_cols; + }; + + explicit Siglip2Preprocessor(const Config& config); + Siglip2Preprocessor(); + ~Siglip2Preprocessor(); + + PreprocessedImage preprocess_from_file(const std::string& image_path); + PreprocessedImage preprocess_from_memory(const unsigned char* img_data, int width, int height, int channels); + SpatialShapeResult compute_spatial_shapes(int height, int width); + +private: + Config config_; + + std::pair compute_pixel_limits() const; + std::vector convert_to_rgb(const unsigned char* img_data, int width, int height, int channels); + std::pair smart_resize(int height, int width); + bool is_image_too_large(int height, int width); + std::pair get_grid_layout(int height, int width); + std::pair find_closest_aspect_ratio(float aspect_ratio, int width, int height); + std::vector resize_image(const unsigned char* img_data, int src_width, int src_height, + int dst_width, int dst_height, int channels); + std::vector normalize_image(const float* img_data, int width, int height, int channels); + std::vector> convert_image_to_patches( + const std::vector& image, int width, int height, int channels, int patch_size); + PreprocessedImage pad_patches(const std::vector>& tile_patches, + const std::vector>& spatial_shapes, + int patch_dim, + int max_patches_per_tile); + int round_by_factor(int number, int factor); +}; + +class AudioProcessor { +public: + struct SpectrogramConfig { + size_t n_fft = 400; + size_t hop_length = 160; + size_t frame_length = 400; + float power = 2.0f; + bool center = true; + const char* pad_mode = "reflect"; + bool onesided = true; + float dither = 0.0f; + float mel_floor = 1e-10f; + const char* log_mel = nullptr; + float reference = 1.0f; + float min_value = 1e-10f; + bool remove_dc_offset = false; + float preemphasis = 0.0f; + bool hann_periodic = true; + float window_a0 = 0.5f; + size_t fft_override = 0; + bool mel_floor_additive = false; + }; + + AudioProcessor(); + ~AudioProcessor(); + + void init_mel_filters(size_t num_frequency_bins, size_t num_mel_filters, + float min_freq, float max_freq, size_t sampling_rate, + const char* norm = "slaney", const char* mel_scale = "slaney"); + + std::vector compute_spectrogram( + const std::vector& waveform, + const SpectrogramConfig& config); + + static std::vector compute_irfft( + const std::vector& complex_input, + size_t n, + const char* norm = "backward"); + + const std::vector& get_mel_filters() const { return mel_filters_; } + + size_t get_num_mel_filters() const { return num_mel_filters_; } + size_t get_num_frequency_bins() const { return num_frequency_bins_; } + +private: + std::vector mel_filters_; + size_t num_frequency_bins_; + size_t num_mel_filters_; +}; + +namespace index { + constexpr uint32_t MAGIC = 0x43414354; + constexpr uint32_t VERSION = 1; + + struct Document { + int id; + std::vector embedding; + std::string content; + std::string metadata; + }; + + struct QueryResult { + int doc_id; + float score; + + QueryResult(int doc_id, float score) : doc_id(doc_id), score(score) {} + }; + + struct QueryOptions { + size_t top_k = 10; + float score_threshold = -1.0f; + }; + + class Index { + public: + Index(const std::string& index_path, const std::string& data_path, size_t embedding_dim); + ~Index(); + + Index(const Index&) = delete; + Index& operator=(const Index&) = delete; + Index(Index&&) = delete; + Index& operator=(Index&&) = delete; + + void add_documents(const std::vector& documents); + void delete_documents(const std::vector& doc_ids); + std::vector get_documents(const std::vector& doc_ids); + std::vector> query(const std::vector>& embeddings, const QueryOptions& options); + void compact(); + + private: + struct IndexHeader { + uint32_t magic; + uint32_t version; + uint32_t embedding_dim; + uint32_t num_documents; + }; + + struct IndexEntry { + int32_t doc_id; + uint64_t data_offset; + uint8_t flags; // bit 0: tombstone + + const __fp16* embedding() const { + return reinterpret_cast(this + 1); + } + + static size_t size(size_t embedding_dim) { + return sizeof(IndexEntry) + embedding_dim * sizeof(__fp16); + } + }; + + struct DataHeader { + uint32_t magic; + uint32_t version; + }; + + struct DataEntry { + uint16_t content_len; + uint16_t metadata_len; + + const char* content() const { + return reinterpret_cast(this + 1); + } + + const char* metadata() const { + return content() + content_len; + } + }; + + void parse_index_header(); + void parse_data_header(); + void build_doc_id_map(); + void validate_documents(const std::vector& documents); + void validate_doc_ids(const std::vector& doc_ids); + ssize_t write_full(int fd, const void* buf, size_t count); + + std::unordered_map doc_id_map_; + + std::string index_path_, data_path_; + size_t embedding_dim_; + size_t index_entry_size_; + uint32_t num_documents_; + + int index_fd_, data_fd_; + void *mapped_index_, *mapped_data_; + size_t index_file_size_, data_file_size_; + }; +} // namespace index + +} +} diff --git a/Frameworks/cactus-ios.xcframework/ios-arm64-simulator/cactus.framework/Headers/gemma_tools.h b/Frameworks/cactus-ios.xcframework/ios-arm64-simulator/cactus.framework/Headers/gemma_tools.h new file mode 100644 index 00000000..f0f9fe26 --- /dev/null +++ b/Frameworks/cactus-ios.xcframework/ios-arm64-simulator/cactus.framework/Headers/gemma_tools.h @@ -0,0 +1,576 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace gemma { + +inline std::string to_upper(const std::string& s) { + std::string result = s; + for (auto& c : result) c = std::toupper(c); + return result; +} + +inline std::string escape(const std::string& s) { + return "" + s + ""; +} + +inline void skip_whitespace(const std::string& json, size_t& pos) { + while (pos < json.length() && std::isspace(json[pos])) pos++; +} + +inline std::string extract_json_string(const std::string& json, size_t& pos) { + std::string value; + while (pos < json.length() && json[pos] != '"') { + if (json[pos] == '\\' && pos + 1 < json.length()) { + pos++; + if (json[pos] == 'n') value += '\n'; + else if (json[pos] == 't') value += '\t'; + else if (json[pos] == 'r') value += '\r'; + else if (json[pos] == '"') value += '"'; + else if (json[pos] == '\\') value += '\\'; + else value += json[pos]; + } else { + value += json[pos]; + } + pos++; + } + if (pos < json.length()) pos++; + return value; +} + +std::string format_argument(const std::string& json, size_t& pos, bool escape_keys); +std::string format_parameters(const std::string& properties_json, const std::string& /*required_json*/); + +inline std::string format_argument(const std::string& json, size_t& pos, bool escape_keys = true) { + skip_whitespace(json, pos); + if (pos >= json.length()) return ""; + + char c = json[pos]; + + if (c == '"') { + pos++; + std::string value = extract_json_string(json, pos); + return escape(value); + } else if (c == '{') { + std::string result = "{"; + pos++; + bool first = true; + + while (pos < json.length()) { + skip_whitespace(json, pos); + if (pos >= json.length() || json[pos] == '}') { pos++; break; } + if (json[pos] == ',') { pos++; continue; } + + if (json[pos] != '"') break; + pos++; + std::string key = extract_json_string(json, pos); + + skip_whitespace(json, pos); + if (pos < json.length() && json[pos] == ':') pos++; + + std::string value = format_argument(json, pos, escape_keys); + + if (!first) result += ","; + first = false; + if (escape_keys) { + result += escape(key) + ":" + value; + } else { + result += key + ":" + value; + } + } + result += "}"; + return result; + } else if (c == '[') { + std::string result = "["; + pos++; + bool first = true; + + while (pos < json.length()) { + skip_whitespace(json, pos); + if (pos >= json.length() || json[pos] == ']') { pos++; break; } + if (json[pos] == ',') { pos++; continue; } + + std::string value = format_argument(json, pos, escape_keys); + + if (!first) result += ","; + first = false; + result += value; + } + result += "]"; + return result; + } else if (json.compare(pos, 4, "true") == 0) { + pos += 4; + return "true"; + } else if (json.compare(pos, 5, "false") == 0) { + pos += 5; + return "false"; + } else if (json.compare(pos, 4, "null") == 0) { + pos += 4; + return "null"; + } else { + size_t start = pos; + while (pos < json.length() && (std::isdigit(json[pos]) || json[pos] == '.' || + json[pos] == '-' || json[pos] == '+' || json[pos] == 'e' || json[pos] == 'E')) { + pos++; + } + return json.substr(start, pos - start); + } +} + +inline std::map parse_json_object_raw(const std::string& json, size_t& pos) { + std::map result; + skip_whitespace(json, pos); + if (pos >= json.length() || json[pos] != '{') return result; + pos++; + + while (pos < json.length()) { + skip_whitespace(json, pos); + if (pos >= json.length() || json[pos] == '}') { pos++; break; } + if (json[pos] == ',') { pos++; continue; } + + if (json[pos] != '"') break; + pos++; + std::string key = extract_json_string(json, pos); + + skip_whitespace(json, pos); + if (pos < json.length() && json[pos] == ':') pos++; + skip_whitespace(json, pos); + + size_t value_start = pos; + if (json[pos] == '"') { + pos++; + while (pos < json.length() && json[pos] != '"') { + if (json[pos] == '\\') pos++; + pos++; + } + pos++; + } else if (json[pos] == '{') { + int depth = 1; + pos++; + while (pos < json.length() && depth > 0) { + if (json[pos] == '{') depth++; + else if (json[pos] == '}') depth--; + else if (json[pos] == '"') { + pos++; + while (pos < json.length() && json[pos] != '"') { + if (json[pos] == '\\') pos++; + pos++; + } + } + pos++; + } + } else if (json[pos] == '[') { + int depth = 1; + pos++; + while (pos < json.length() && depth > 0) { + if (json[pos] == '[') depth++; + else if (json[pos] == ']') depth--; + else if (json[pos] == '"') { + pos++; + while (pos < json.length() && json[pos] != '"') { + if (json[pos] == '\\') pos++; + pos++; + } + } + pos++; + } + } else { + while (pos < json.length() && json[pos] != ',' && json[pos] != '}') pos++; + } + result[key] = json.substr(value_start, pos - value_start); + } + return result; +} + +inline std::string get_json_string_value(const std::string& json, size_t pos) { + skip_whitespace(json, pos); + if (pos < json.length() && json[pos] == '"') { + pos++; + return extract_json_string(json, pos); + } + return ""; +} + +inline std::string format_parameters(const std::string& properties_json, const std::string& /*required_json*/) { + static const std::set standard_keys = {"description", "type", "properties", "required", "nullable"}; + + size_t pos = 0; + auto properties = parse_json_object_raw(properties_json, pos); + + std::string result; + bool first = true; + + for (const auto& [key, value_json] : properties) { + if (standard_keys.count(key)) continue; + + if (!first) result += ","; + first = false; + + size_t prop_pos = 0; + auto prop_obj = parse_json_object_raw(value_json, prop_pos); + + result += key + ":{"; + + if (prop_obj.count("description")) { + std::string desc = get_json_string_value(prop_obj["description"], 0); + result += "description:" + escape(desc); + } + + std::string type_val; + if (prop_obj.count("type")) { + type_val = get_json_string_value(prop_obj["type"], 0); + } + + if (to_upper(type_val) == "STRING") { + if (prop_obj.count("enum")) { + size_t enum_pos = 0; + std::string enum_formatted = format_argument(prop_obj["enum"], enum_pos, true); + result += ",enum:" + enum_formatted; + } + } else if (to_upper(type_val) == "OBJECT") { + if (prop_obj.count("properties")) { + std::string nested_required; + if (prop_obj.count("required")) { + nested_required = prop_obj["required"]; + } + result += ",properties:{" + format_parameters(prop_obj["properties"], nested_required) + "}"; + } + if (prop_obj.count("required")) { + std::string req_items; + size_t req_pos = 0; + skip_whitespace(prop_obj["required"], req_pos); + if (req_pos < prop_obj["required"].length() && prop_obj["required"][req_pos] == '[') { + req_pos++; + bool req_first = true; + while (req_pos < prop_obj["required"].length()) { + skip_whitespace(prop_obj["required"], req_pos); + if (prop_obj["required"][req_pos] == ']') break; + if (prop_obj["required"][req_pos] == ',') { req_pos++; continue; } + if (prop_obj["required"][req_pos] == '"') { + req_pos++; + std::string req_item = extract_json_string(prop_obj["required"], req_pos); + if (!req_first) req_items += ","; + req_first = false; + req_items += escape(req_item); + } + } + } + if (!req_items.empty()) { + result += ",required:[" + req_items + "]"; + } + } + } else if (to_upper(type_val) == "ARRAY") { + if (prop_obj.count("items")) { + result += ",items:{"; + size_t items_pos = 0; + auto items_obj = parse_json_object_raw(prop_obj["items"], items_pos); + bool items_first = true; + + for (const auto& [item_key, item_value] : items_obj) { + if (!items_first) result += ","; + items_first = false; + + if (item_key == "properties") { + std::string items_required; + if (items_obj.count("required")) { + items_required = items_obj["required"]; + } + result += "properties:{" + format_parameters(item_value, items_required) + "}"; + } else if (item_key == "required") { + result += "required:["; + size_t req_pos = 0; + skip_whitespace(item_value, req_pos); + if (req_pos < item_value.length() && item_value[req_pos] == '[') { + req_pos++; + bool req_first = true; + while (req_pos < item_value.length()) { + skip_whitespace(item_value, req_pos); + if (item_value[req_pos] == ']') break; + if (item_value[req_pos] == ',') { req_pos++; continue; } + if (item_value[req_pos] == '"') { + req_pos++; + std::string req_item = extract_json_string(item_value, req_pos); + if (!req_first) result += ","; + req_first = false; + result += escape(req_item); + } + } + } + result += "]"; + } else if (item_key == "type") { + std::string item_type = get_json_string_value(item_value, 0); + result += "type:" + escape(to_upper(item_type)); + } else { + size_t val_pos = 0; + result += item_key + ":" + format_argument(item_value, val_pos, true); + } + } + result += "}"; + } + } + + if (!type_val.empty()) { + result += ",type:" + escape(to_upper(type_val)); + } + + result += "}"; + } + + return result; +} + +inline std::string format_function_declaration(const std::string& name, + const std::string& description, + const std::string& params_json) { + std::string result = "declaration:" + name + "{"; + result += "description:" + escape(description); + + if (!params_json.empty()) { + result += ",parameters:{"; + + size_t pos = 0; + auto params = parse_json_object_raw(params_json, pos); + + if (params.count("properties")) { + std::string required_json; + if (params.count("required")) { + required_json = params["required"]; + } + result += "properties:{" + format_parameters(params["properties"], required_json) + "}"; + } + + if (params.count("required")) { + std::string req_items; + size_t req_pos = 0; + skip_whitespace(params["required"], req_pos); + if (req_pos < params["required"].length() && params["required"][req_pos] == '[') { + req_pos++; + bool first = true; + while (req_pos < params["required"].length()) { + skip_whitespace(params["required"], req_pos); + if (params["required"][req_pos] == ']') break; + if (params["required"][req_pos] == ',') { req_pos++; continue; } + if (params["required"][req_pos] == '"') { + req_pos++; + std::string item = extract_json_string(params["required"], req_pos); + if (!first) req_items += ","; + first = false; + req_items += escape(item); + } + } + } + if (!req_items.empty()) { + result += ",required:[" + req_items + "]"; + } + } + + if (params.count("type")) { + std::string type_val = get_json_string_value(params["type"], 0); + result += ",type:" + escape(to_upper(type_val)); + } + + result += "}"; + } + + result += "}"; + return result; +} + +template +inline std::string format_tools(const std::vector& tools, bool use_pipe_tags = false) { + if (tools.empty()) return ""; + + const char* decl_start = use_pipe_tags ? "<|tool>" : ""; + const char* decl_end = use_pipe_tags ? "" : ""; + + std::string result; + for (const auto& tool : tools) { + result += decl_start; + std::string params_json; + auto it = tool.parameters.find("schema"); + if (it != tool.parameters.end()) { + params_json = it->second; + } + + result += format_function_declaration(tool.name, tool.description, params_json); + result += decl_end; + } + return result; +} + + +inline size_t match_quote_tag(const std::string& s, size_t pos) { + if (s.compare(pos, 8, "") == 0) return 8; + if (s.compare(pos, 5, "<|\"|>") == 0) return 5; + return 0; +} + +inline size_t find_quote_tag(const std::string& s, size_t pos) { + size_t e = s.find("", pos); + size_t t = s.find("<|\"|>", pos); + if (e == std::string::npos) return t; + if (t == std::string::npos) return e; + return std::min(e, t); +} + +inline std::string unescape(const std::string& s) { + const std::string ESCAPE_TAG = ""; + std::string result = s; + size_t pos = 0; + while ((pos = result.find(ESCAPE_TAG, pos)) != std::string::npos) { + result.erase(pos, ESCAPE_TAG.length()); + } + return result; +} + +inline std::string args_to_json(const std::string& args_content) { + std::string result = "{"; + size_t pos = 0; + bool first = true; + + if (!args_content.empty() && args_content[0] == '{') pos = 1; + + while (pos < args_content.length()) { + while (pos < args_content.length() && std::isspace(args_content[pos])) pos++; + if (pos >= args_content.length() || args_content[pos] == '}') break; + if (args_content[pos] == ',') { pos++; continue; } + + size_t key_start = pos; + while (pos < args_content.length() && args_content[pos] != ':') pos++; + std::string key = args_content.substr(key_start, pos - key_start); + if (pos < args_content.length()) pos++; + + std::string value; + while (pos < args_content.length() && std::isspace(args_content[pos])) pos++; + + if (pos < args_content.length()) { + size_t qtag_len = match_quote_tag(args_content, pos); + if (qtag_len > 0) { + pos += qtag_len; + size_t val_end = find_quote_tag(args_content, pos); + if (val_end != std::string::npos) { + value = "\"" + args_content.substr(pos, val_end - pos) + "\""; + pos = val_end + match_quote_tag(args_content, val_end); + } + } else if (args_content[pos] == '{') { + int depth = 1; + size_t start = pos; + pos++; + while (pos < args_content.length() && depth > 0) { + if (args_content[pos] == '{') depth++; + else if (args_content[pos] == '}') depth--; + pos++; + } + value = args_to_json(args_content.substr(start, pos - start)); + } else if (args_content[pos] == '[') { + int depth = 1; + size_t start = pos; + pos++; + while (pos < args_content.length() && depth > 0) { + if (args_content[pos] == '[') depth++; + else if (args_content[pos] == ']') depth--; + pos++; + } + std::string arr_content = args_content.substr(start + 1, pos - start - 2); + value = "["; + size_t arr_pos = 0; + bool first_item = true; + while (arr_pos < arr_content.length()) { + while (arr_pos < arr_content.length() && (std::isspace(arr_content[arr_pos]) || arr_content[arr_pos] == ',')) arr_pos++; + if (arr_pos >= arr_content.length()) break; + + if (!first_item) value += ","; + first_item = false; + + size_t aq_len = match_quote_tag(arr_content, arr_pos); + if (aq_len > 0) { + arr_pos += aq_len; + size_t end = find_quote_tag(arr_content, arr_pos); + if (end != std::string::npos) { + value += "\"" + arr_content.substr(arr_pos, end - arr_pos) + "\""; + arr_pos = end + match_quote_tag(arr_content, end); + } + } else { + size_t end = arr_content.find_first_of(",]", arr_pos); + if (end == std::string::npos) end = arr_content.length(); + value += arr_content.substr(arr_pos, end - arr_pos); + arr_pos = end; + } + } + value += "]"; + } else { + size_t val_start = pos; + while (pos < args_content.length() && args_content[pos] != ',' && args_content[pos] != '}') { + pos++; + } + value = args_content.substr(val_start, pos - val_start); + while (!value.empty() && std::isspace(value.back())) value.pop_back(); + } + } + + if (!first) result += ","; + first = false; + result += "\"" + key + "\":" + value; + } + + result += "}"; + return result; +} + +inline void parse_function_calls(std::string& response, std::vector& function_calls) { + + const std::string CALL_START = (response.find("<|tool_call>") != std::string::npos) + ? "<|tool_call>" : ""; + const std::string CALL_END = (CALL_START == "<|tool_call>") + ? "" : ""; + size_t pos = 0; + + while ((pos = response.find(CALL_START, pos)) != std::string::npos) { + size_t content_start = pos + CALL_START.length(); + size_t call_end_pos = response.find(CALL_END, content_start); + + size_t content_end = (call_end_pos != std::string::npos) ? call_end_pos : response.length(); + std::string call_content = response.substr(content_start, content_end - content_start); + + if (call_content.compare(0, 5, "call:") == 0) { + size_t brace_pos = call_content.find('{'); + + if (brace_pos == std::string::npos) { + size_t sep_pos = call_content.find_first_of(", ", 5); + if (sep_pos != std::string::npos) { + std::string func_name = call_content.substr(5, sep_pos - 5); + size_t args_start = sep_pos + 1; + while (args_start < call_content.length() && + (call_content[args_start] == ' ' || call_content[args_start] == ',')) { + args_start++; + } + std::string args_content = "{" + call_content.substr(args_start); + if (args_content.back() != '}') args_content += "}"; + + std::string args_json = args_to_json(args_content); + std::string json_call = "{\"name\":\"" + func_name + "\",\"arguments\":" + args_json + "}"; + function_calls.push_back(json_call); + } + } else { + std::string func_name = call_content.substr(5, brace_pos - 5); + std::string args_content = call_content.substr(brace_pos); + if (args_content.back() != '}') args_content += "}"; + + std::string args_json = args_to_json(args_content); + std::string json_call = "{\"name\":\"" + func_name + "\",\"arguments\":" + args_json + "}"; + function_calls.push_back(json_call); + } + } + + size_t erase_end = (call_end_pos != std::string::npos) ? + call_end_pos + CALL_END.length() : response.length(); + response.erase(pos, erase_end - pos); + } +} + +} // namespace gemma \ No newline at end of file diff --git a/Frameworks/cactus-ios.xcframework/ios-arm64-simulator/cactus.framework/Headers/graph.h b/Frameworks/cactus-ios.xcframework/ios-arm64-simulator/cactus.framework/Headers/graph.h new file mode 100644 index 00000000..9ad6fe38 --- /dev/null +++ b/Frameworks/cactus-ios.xcframework/ios-arm64-simulator/cactus.framework/Headers/graph.h @@ -0,0 +1,775 @@ +#ifndef GRAPH_H +#define GRAPH_H + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace cactus { + +enum class LogLevel { + DEBUG = 0, + INFO = 1, + WARN = 2, + ERROR = 3, + NONE = 4 +}; + +class Logger { +public: + static Logger& instance() { + static Logger logger; + return logger; + } + + void set_level(LogLevel level) { min_level_ = level; } + LogLevel get_level() const { return min_level_; } + + void set_callback(std::function cb) { + std::lock_guard lock(mutex_); + callback_ = cb; + } + + void log(LogLevel level, const std::string& component, const std::string& message) { + if (level < min_level_) return; + + std::lock_guard lock(mutex_); + + if (callback_) { + callback_(level, component, message); + } else { + std::cerr << "[" << level_string(level) << "] [" << component << "] " << message << std::endl; + } + + if (level == LogLevel::ERROR) { + last_error_ = "[" + component + "] " + message; + } + } + + const std::string& last_error() const { return last_error_; } + void clear_error() { last_error_.clear(); } + +private: + Logger() : min_level_(LogLevel::WARN) {} + + static const char* level_string(LogLevel level) { + switch (level) { + case LogLevel::DEBUG: return "DEBUG"; + case LogLevel::INFO: return "INFO"; + case LogLevel::WARN: return "WARN"; + case LogLevel::ERROR: return "ERROR"; + default: return "?"; + } + } + + LogLevel min_level_; + std::mutex mutex_; + std::string last_error_; + std::function callback_; +}; + +} // namespace cactus + +#define CACTUS_LOG(level, component, msg) \ + do { \ + if (static_cast(level) >= static_cast(cactus::Logger::instance().get_level())) { \ + std::ostringstream _cactus_log_ss; \ + _cactus_log_ss << msg; \ + cactus::Logger::instance().log(level, component, _cactus_log_ss.str()); \ + } \ + } while(0) + +#define CACTUS_LOG_DEBUG(component, msg) CACTUS_LOG(cactus::LogLevel::DEBUG, component, msg) +#define CACTUS_LOG_INFO(component, msg) CACTUS_LOG(cactus::LogLevel::INFO, component, msg) +#define CACTUS_LOG_WARN(component, msg) CACTUS_LOG(cactus::LogLevel::WARN, component, msg) +#define CACTUS_LOG_ERROR(component, msg) CACTUS_LOG(cactus::LogLevel::ERROR, component, msg) + +namespace GraphFile { + class MappedFile; + struct SerializedGraph; +} + +enum class Precision { + INT8, + FP16, + FP32, + INT4 +}; + +enum class ComputeBackend { + CPU, + NPU +}; + +enum class Activation { + SILU, + GELU, + GELU_ERF, + RELU, + SIGMOID, + TANH +}; + +enum class OpType { + INPUT, PRECISION_CAST, + ADD, ADD_CLIPPED, SUBTRACT, MULTIPLY, DIVIDE, + ABS, POW, FLATTEN, VIEW, + MATMUL, TRANSPOSE, RESHAPE, SLICE, GATHER, EMBEDDING, + BILINEAR_INTERPOLATION, + SUM, MEAN, VARIANCE, MIN, MAX, + RMS_NORM, ROPE, ROPE_GPTJ, SOFTMAX, ATTENTION, ATTENTION_INT8_HYBRID, REL_POS_BIAS, CONV1D_CAUSAL, CONV1D_K3, CONV1D_K7S3, CONV1D, CONV1D_SAME_DEPTHWISE_K9, CONV1D_POINTWISE, CONV2D_K3S2P1, CONV2D_DEPTHWISE_K3S2P1, CONV2D_POINTWISE_1X1, GLU, BATCHNORM, + SCALAR_ADD, SCALAR_SUBTRACT, SCALAR_MULTIPLY, SCALAR_DIVIDE, SCALAR_EXP, SCALAR_SQRT, SCALAR_COS, SCALAR_SIN, SCALAR_LOG, + RELU, SILU, GELU, GELU_ERF, SIGMOID, TANH, + SAMPLE, CONCAT, CAT, + SCATTER_TOPK, + TOPK, LAYERNORM, GROUPNORM, + MOE_LAYER, + INDEX, + PERSISTENT, + QUANTIZE_ACTIVATIONS, + LSTM_CELL, + GATED_DELTANET_DECODE, + GATED_DELTANET_PREFILL, + STFT, + ALTUP_PREDICT, + ALTUP_CORRECT, + GAUSSIAN_TOPK, + MAXPOOL1D, + BILSTM_SEQUENCE, + LEAKY_RELU, + CONV2D_K3S1P1, + STATS_POOL, + WEIGHTED_STATS_POOL +}; + +struct PrecisionTraits { + static constexpr size_t size_of(Precision prec) { + switch (prec) { + case Precision::INT8: return 1; + case Precision::FP16: return 2; + case Precision::FP32: return 4; + case Precision::INT4: return 1; + } + return 1; + } + + static constexpr size_t packed_size_of(Precision prec, size_t count) { + switch (prec) { + case Precision::INT4: return (count + 1) / 2; + default: return count * size_of(prec); + } + } + + static size_t byte_offset_of(Precision prec, size_t element_offset) { + switch (prec) { + case Precision::INT4: + assert(element_offset % 32 == 0 && "INT4 byte offset must be group-aligned (multiple of 32)"); + return element_offset / 2; + default: return element_offset * size_of(prec); + } + } + + static constexpr bool is_integer(Precision prec) { + switch (prec) { + case Precision::INT8: return true; + case Precision::INT4: return true; + case Precision::FP16: return false; + case Precision::FP32: return false; + } + return true; + } + + static constexpr bool is_floating_point(Precision prec) { + switch (prec) { + case Precision::INT8: return false; + case Precision::INT4: return false; + case Precision::FP16: return true; + case Precision::FP32: return true; + } + return false; + } +}; + +namespace Quantization { + void int8_to_fp32(const int8_t* src, float* dst, size_t count, float scale = 1.0f); + void fp32_to_int8(const float* src, int8_t* dst, size_t count, float scale = 1.0f); + void fp16_to_fp32(const __fp16* src, float* dst, size_t count); + void fp32_to_fp16(const float* src, __fp16* dst, size_t count); + void int8_to_fp16(const int8_t* src, __fp16* dst, size_t count, float scale = 1.0f); + void fp16_to_int8(const __fp16* src, int8_t* dst, size_t count, float scale = 1.0f); +} + +struct TensorConfig { + Precision default_precision = Precision::INT8; + Precision compute_precision = Precision::INT8; + Precision output_precision = Precision::INT8; + bool auto_mixed_precision = false; + + static TensorConfig& global(); +}; + +struct BroadcastInfo { + std::vector output_shape; + bool needs_broadcasting; + + static BroadcastInfo compute(const std::vector& lhs, const std::vector& rhs); +}; + +class BufferPool; + +struct BufferDesc { + std::vector shape; + size_t total_size; + size_t byte_size; + std::unique_ptr data; + void* external_data; + char* pooled_data; + Precision precision; + + size_t group_size = 0; + size_t num_groups = 0; + void* scales_data = nullptr; + std::unique_ptr owned_scales; + + bool is_interleaved = false; + size_t original_N = 0; + + void* activation_scales_data = nullptr; + std::unique_ptr owned_activation_scales; + size_t num_rows_for_activation_scales = 0; + + BufferDesc(); + BufferDesc(const std::vector& s, Precision prec = Precision::INT8); + ~BufferDesc(); + + BufferDesc(BufferDesc&& other) noexcept; + BufferDesc& operator=(BufferDesc&& other) noexcept; + + BufferDesc(const BufferDesc&) = delete; + BufferDesc& operator=(const BufferDesc&) = delete; + + void* get_data(); + const void* get_data() const; + + template + T* data_as() { return static_cast(get_data()); } + + template + const T* data_as() const { return static_cast(get_data()); } + + const __fp16* scales_as_fp16() const { + return reinterpret_cast(scales_data); + } + + bool is_grouped_int8() const { + return precision == Precision::INT8 && group_size > 0; + } + + bool is_grouped_int4() const { + return precision == Precision::INT4 && group_size > 0; + } + + void set_grouped_scales(size_t gs, size_t ng, void* scales_ptr) { + group_size = gs; + num_groups = ng; + scales_data = scales_ptr; + } + + void set_interleaved(bool interleaved, size_t orig_n) { + is_interleaved = interleaved; + original_N = orig_n; + } + + bool has_activation_scales() const { + return activation_scales_data != nullptr && num_rows_for_activation_scales > 0; + } + const float* activation_scales_as_float() const { + return reinterpret_cast(activation_scales_data); + } + float* activation_scales_as_float() { + return reinterpret_cast(activation_scales_data); + } + void allocate_activation_scales(size_t num_rows) { + num_rows_for_activation_scales = num_rows; + owned_activation_scales = std::make_unique(num_rows * sizeof(float)); + activation_scales_data = owned_activation_scales.get(); + } + void set_activation_scales(void* scales_ptr, size_t num_rows) { + activation_scales_data = scales_ptr; + num_rows_for_activation_scales = num_rows; + } + + void allocate(); + void allocate_from_pool(BufferPool& pool); + void release_to_pool(BufferPool& pool); + void set_external(void* ptr); +}; + +struct OpParams { + float scalar = 0.0f; + float scale = 1.0f; + float theta = 10000.0f; + float epsilon = 1e-6f; + int axis = -1; + bool pretransposed_rhs = false; + size_t position_offset = 0; + size_t slice_start = 0; + size_t slice_length = 0; + size_t window_size = 0; + bool is_causal = true; + bool attention_mask_is_additive = false; + float logit_cap = 0.0f; + std::vector new_shape; + std::vector permutation; + Precision output_precision = Precision::INT8; + BroadcastInfo broadcast_info; + ComputeBackend backend = ComputeBackend::CPU; + + size_t dilation = 1; + size_t stride = 1; + float temperature = 1.0f; + float top_p = 1.0f; + float min_p = 0.15f; + float repetition_penalty = 1.1f; + size_t top_k = 0; + size_t random_seed = 0; + + size_t index_value = 0; + size_t num_classes = 0; + size_t num_groups = 0; + size_t dst_height = 0; + size_t dst_width = 0; + bool align_corners = true; + bool normalize_routing = false; + size_t num_experts = 0; + size_t num_experts_per_tok = 0; + bool moe_gated = true; + Activation activation = Activation::SILU; + + std::vector bias_values; + std::vector bias_indices; + + const int8_t* cached_keys_int8 = nullptr; + const int8_t* cached_values_int8 = nullptr; + const float* cached_k_scales = nullptr; + const float* cached_v_scales = nullptr; + size_t cache_seq_len = 0; + size_t num_kv_heads = 0; + size_t head_dim = 0; + size_t num_fft_bins = 0; + size_t chunk_size = 0; + size_t num_altup_inputs = 0; + size_t v_head_dim = 0; + size_t kernel_size = 0; +}; + +struct GraphNode { + size_t id; + OpType op_type; + std::vector input_ids; + BufferDesc output_buffer; + OpParams params; + + GraphNode(size_t node_id, OpType type); +}; + +using nodes_vector = std::vector>; +using node_index_map_t = std::unordered_map; + +inline const BufferDesc& get_input(const GraphNode& node, size_t idx, + const nodes_vector& nodes, + const node_index_map_t& node_index_map) { + return nodes[node_index_map.at(node.input_ids[idx])]->output_buffer; +} + +struct AxisDims { + size_t outer, axis_size, inner; + static AxisDims from_shape(const std::vector& shape, size_t axis) { + AxisDims d; + d.outer = 1; + for (size_t i = 0; i < axis; i++) d.outer *= shape[i]; + d.axis_size = shape[axis]; + d.inner = 1; + for (size_t i = axis + 1; i < shape.size(); i++) d.inner *= shape[i]; + return d; + } +}; + +template +void dispatch_binary_op(OpType op, const T* lhs, const T* rhs, T* output, size_t count); + +template +void dispatch_unary_op(OpType op, const T* input, T* output, size_t count, float param = 0.0f); + +void compute_node_optimized(GraphNode& node, const std::vector>& nodes, const std::unordered_map& node_index_map); +void compute_matmul_node(GraphNode& node, const std::vector>& nodes, const std::unordered_map& node_index_map); +void compute_transpose_node(GraphNode& node, const std::vector>& nodes, const std::unordered_map& node_index_map); +void compute_reduce_node(GraphNode& node, const std::vector>& nodes, const std::unordered_map& node_index_map); +void compute_fused_node(GraphNode& node, const std::vector>& nodes, const std::unordered_map& node_index_map); +void compute_reshape_node(GraphNode& node, const std::vector>& nodes, const std::unordered_map& node_index_map); +void compute_precision_cast_node(GraphNode& node, const std::vector>& nodes, const std::unordered_map& node_index_map); +void compute_sample_node(GraphNode& node, const std::vector>& nodes, const std::unordered_map& node_index_map); +void compute_scatter_topk_node(GraphNode& node, const std::vector>& nodes, const std::unordered_map& node_index_map); +void compute_topk_node(GraphNode& node, const std::vector>& nodes, const std::unordered_map& node_index_map); +void compute_layernorm_node(GraphNode& node, const std::vector>& nodes, const std::unordered_map& node_index_map); +void compute_groupnorm_node(GraphNode& node, const std::vector>& nodes, const std::unordered_map& node_index_map); +void compute_persistent_node(GraphNode& node, const std::vector>& nodes, const std::unordered_map& node_index_map); +void compute_index_node(GraphNode& node, const std::vector>& nodes, const std::unordered_map& node_index_map); +void compute_lstm_cell_node(GraphNode& node, const std::vector>& nodes, const std::unordered_map& node_index_map); +void compute_gated_deltanet_decode_node(GraphNode& node, const std::vector>& nodes, const std::unordered_map& node_index_map); +void compute_gated_deltanet_prefill_node(GraphNode& node, const std::vector>& nodes, const std::unordered_map& node_index_map); +void compute_altup_predict_node(GraphNode& node, const std::vector>& nodes, const std::unordered_map& node_index_map); +void compute_altup_correct_node(GraphNode& node, const std::vector>& nodes, const std::unordered_map& node_index_map); +void compute_maxpool1d_node(GraphNode& node, const std::vector>& nodes, const std::unordered_map& node_index_map); +void compute_bilstm_sequence_node(GraphNode& node, const std::vector>& nodes, const std::unordered_map& node_index_map); +void compute_conv2d_k3s1p1_node(GraphNode& node, const std::vector>& nodes, const std::unordered_map& node_index_map); +void compute_stats_pool_node(GraphNode& node, const std::vector>& nodes, const std::unordered_map& node_index_map); +void compute_weighted_stats_pool_node(GraphNode& node, const std::vector>& nodes, const std::unordered_map& node_index_map); + +void shrink_thread_local_buffers(); +class BufferPool { +public: + BufferPool() = default; + ~BufferPool() = default; + + BufferPool(const BufferPool&) = delete; + BufferPool& operator=(const BufferPool&) = delete; + BufferPool(BufferPool&&) noexcept = default; + BufferPool& operator=(BufferPool&&) noexcept = default; + + char* acquire(size_t byte_size); + void release(char* ptr, size_t byte_size); + void clear(); + + size_t active_bytes() const { return active_bytes_; } + size_t pool_bytes() const { return pool_bytes_; } + size_t peak_bytes() const { return peak_bytes_; } + +private: + std::unordered_map>> free_buffers_; + size_t active_bytes_ = 0; + size_t pool_bytes_ = 0; + size_t peak_bytes_ = 0; + + size_t round_up_size(size_t size) const; +}; + +namespace ValidationUtils { + void validate_tensor_dims(const std::vector& shape, size_t required_dims, const std::string& op_name); + void validate_precision(Precision actual, Precision required, const std::string& op_name); + void validate_input_count(size_t actual, size_t required, const std::string& op_name); +} + + +class CactusGraph { +public: + CactusGraph(); + ~CactusGraph() = default; + + CactusGraph(const CactusGraph&) = delete; + CactusGraph& operator=(const CactusGraph&) = delete; + CactusGraph(CactusGraph&&) noexcept = default; + CactusGraph& operator=(CactusGraph&&) noexcept = default; + + struct DebugNodeEntry { + uint32_t layer_idx; + std::string name; + size_t node_id; + }; + + void save(const std::string& path); + static CactusGraph load(const std::string& path); + + size_t input(const std::vector& shape, Precision precision = Precision::INT8); + size_t precision_cast(size_t input, Precision target_precision); + size_t quantize_activations(size_t input); + + size_t add(size_t input1, size_t input2); + size_t add_clipped(size_t input1, size_t input2); + size_t subtract(size_t input1, size_t input2); + size_t multiply(size_t input1, size_t input2); + size_t divide(size_t input1, size_t input2); + + size_t scalar_add(size_t input, float value); + size_t scalar_subtract(size_t input, float value); + size_t scalar_multiply(size_t input, float value); + size_t scalar_divide(size_t input, float value); + size_t scalar_exp(size_t input); + size_t scalar_sqrt(size_t input); + size_t scalar_cos(size_t input); + size_t scalar_sin(size_t input); + size_t scalar_log(size_t input); + + size_t relu(size_t input); + size_t silu(size_t input); + size_t gelu(size_t input); + size_t gelu_erf(size_t input); + size_t sigmoid(size_t input); + size_t tanh(size_t input); + size_t glu(size_t input, int axis = -1); + + size_t abs(size_t input); + size_t pow(size_t input, float exponent); + size_t view(size_t input, const std::vector& new_shape); + size_t flatten(size_t input, int start_dim = 0, int end_dim = -1); + + size_t matmul(size_t input1, size_t input2, bool pretransposed_rhs = false, ComputeBackend backend = ComputeBackend::CPU); + size_t transpose(size_t input, ComputeBackend backend = ComputeBackend::CPU); + size_t transposeN(size_t input, const std::vector& permutation, ComputeBackend backend = ComputeBackend::CPU); + size_t reshape(size_t input, const std::vector& new_shape); + size_t slice(size_t input, int axis, size_t start, size_t length); + size_t index(size_t input, size_t index_value, int dim); + + size_t sum(size_t input, int axis); + size_t mean(size_t input, int axis); + size_t variance(size_t input, int axis); + size_t min(size_t input, int axis); + size_t max(size_t input, int axis); + + size_t gather(size_t embeddings, size_t indices); + size_t mmap_embeddings(const std::string& filename); + size_t mmap_weights(const std::string& filename); + void set_grouped_scales(size_t node_id, size_t group_size, size_t num_groups, void* scales_ptr); + void set_interleaved(size_t node_id, bool interleaved, size_t original_N); + + void release_weight_pages(size_t node_id); + void prefetch_weight_pages(size_t node_id); + void release_all_weight_pages(); + size_t embedding(const std::string& filename, size_t indices); + size_t embedding(size_t embedding_tensor, size_t indices); + size_t bilinear_interpolation(size_t pos_embeds, size_t dst_height, size_t dst_width, bool align_corners = true); + + size_t layernorm(size_t input, size_t weight, size_t bias, float epsilon = 1e-5f); + size_t layernorm(size_t input, size_t weight, float epsilon = 1e-5f); // No bias version + size_t groupnorm(size_t input, size_t weight, size_t bias, size_t num_groups = 32, float epsilon = 1e-5f); + size_t batchnorm(size_t input, size_t weight, size_t bias, size_t running_mean, size_t running_var, int axis = 1, float epsilon = 1e-5f); + size_t topk(size_t input, size_t k); + size_t moe_layer(size_t hidden, + size_t routing_probs, + size_t topk_indices, + const std::vector& w1_weights, + const std::vector& w3_weights, + const std::vector& w2_weights, + size_t num_experts, + size_t num_experts_per_tok, + bool normalize_routing, + float epsilon, + float routed_scaling_factor, + Activation activation = Activation::SILU, + size_t per_expert_scale = 0); + size_t moe_layer(size_t hidden, + size_t routing_probs, + size_t topk_indices, + const std::vector& w1_weights, + const std::vector& w2_weights, + size_t num_experts, + size_t num_experts_per_tok, + bool normalize_routing, + float epsilon, + float routed_scaling_factor, + Activation activation); + size_t rms_norm(size_t input, size_t weight, float epsilon = 1e-5f); + size_t rope(size_t input, float theta, size_t position_offset = 0, ComputeBackend backend = ComputeBackend::CPU); + size_t rope_gptj(size_t input, float theta, size_t position_offset = 0, size_t rot_dim = 0, ComputeBackend backend = ComputeBackend::CPU); + size_t softmax(size_t input, int axis = -1); + size_t attention(size_t query, size_t key, size_t value, float scale, bool is_causal = true, ComputeBackend backend = ComputeBackend::CPU); + size_t attention(size_t query, size_t key, size_t value, float scale, size_t position_offset, ComputeBackend backend = ComputeBackend::CPU); + size_t attention(size_t query, size_t key, size_t value, float scale, size_t position_offset, size_t window_size, ComputeBackend backend = ComputeBackend::CPU); + size_t attention_masked(size_t query, size_t key, size_t value, size_t mask, float scale, + bool is_causal = true, ComputeBackend backend = ComputeBackend::CPU, + bool additive_mask = false, size_t position_offset = 0, size_t window_size = 0, + float logit_cap = 0.0f); + size_t rel_pos_bias(size_t query, size_t relative_key, float scale); + + size_t attention_int8_hybrid(size_t query, size_t key_new, size_t value_new, float scale, size_t position_offset, + const int8_t* cached_keys, const int8_t* cached_values, + const float* k_scales, const float* v_scales, + size_t cache_len, size_t num_kv_heads, size_t head_dim, + size_t window_size = 0, size_t v_head_dim = 0); + + size_t conv1d_causal(size_t input, size_t weight, size_t kernel_size, size_t dilation = 1); + size_t conv1d_k3(size_t input, size_t weight, size_t stride); + size_t conv1d_k7s3(size_t input, size_t weight, size_t bias); + size_t conv1d(size_t input, size_t weight, size_t stride); + size_t conv1d(size_t input, size_t weight, size_t bias, size_t stride); + size_t conv1d_same_depthwise_k9(size_t input, size_t weight); + size_t conv1d_same_depthwise_k9(size_t input, size_t weight, size_t bias); + size_t conv1d_pointwise(size_t input, size_t weight); + size_t conv1d_pointwise(size_t input, size_t weight, size_t bias); + size_t conv2d_k3s2p1(size_t input, size_t weight); + size_t conv2d_k3s2p1(size_t input, size_t weight, size_t bias); + size_t conv2d_depthwise_k3s2p1(size_t input, size_t weight); + size_t conv2d_depthwise_k3s2p1(size_t input, size_t weight, size_t bias); + size_t conv2d_pointwise_1x1(size_t input, size_t weight); + size_t conv2d_pointwise_1x1(size_t input, size_t weight, size_t bias); + + size_t lstm_cell(size_t input, size_t h_prev, size_t c_prev, size_t weight_ih, size_t weight_hh, size_t bias_ih, size_t bias_hh); + size_t gated_deltanet_decode(size_t query, size_t key, size_t value, size_t gate_log, size_t beta, + size_t initial_state, float scale = 0.0f); + size_t gated_deltanet_prefill(size_t query, size_t key, size_t value, size_t gate_log, size_t beta, + size_t initial_state, size_t chunk_size = 64, float scale = 0.0f); + size_t stft(size_t input, size_t weight, size_t stride, size_t num_fft_bins); + + size_t altup_predict(size_t coefs, const size_t* streams, size_t num_streams); + size_t altup_correct(size_t coefs, size_t innovation, const size_t* predictions, size_t num_predictions); + + size_t gaussian_topk(size_t input, float ppf); + + size_t maxpool1d(size_t input, size_t kernel_size, size_t stride); + size_t leaky_relu(size_t input, float negative_slope = 0.01f); + size_t bilstm_sequence(size_t input, size_t w_ih_fwd, size_t w_hh_fwd, size_t b_ih_fwd, size_t b_hh_fwd, + size_t w_ih_bwd, size_t w_hh_bwd, size_t b_ih_bwd, size_t b_hh_bwd); + size_t conv2d_k3s1p1(size_t input, size_t weight); + size_t conv2d_k3s1p1(size_t input, size_t weight, size_t bias); + size_t stats_pool(size_t input); + size_t weighted_stats_pool(size_t input, size_t weights); + + size_t sample(size_t logits, float temperature = 0.6f, float top_p = 0.95f, size_t top_k = 20, + const std::unordered_map& logit_bias = {}); + size_t sample_with_options(size_t logits, float temperature, float top_p, float min_p, float repetition_penalty, + size_t top_k, const std::unordered_map& logit_bias = {}); + + size_t concat(size_t input1, size_t input2, int axis = 0); + size_t cat(const std::vector& inputs, int axis); + size_t scatter_topk(size_t indices, size_t values, size_t num_classes); + + void set_input(size_t node_id, const void* data, Precision precision); + void set_external_input(size_t node_id, void* data, Precision precision); + void* get_output(size_t node_id); + + void execute(const std::string& profile_file = ""); + void hard_reset(); + void soft_reset(); + void soft_reset_keep_pool(); + void set_prefill_mode(bool enabled) { prefill_mode_ = enabled; } + + void register_debug_node(uint32_t layer_idx, const std::string& name, size_t node_id); + void capture_debug_node(uint32_t layer_idx, const std::string& name, size_t node_id); + const std::vector& get_debug_nodes() const; + void clear_debug_nodes(); + + size_t add_node(OpType op_type, const std::vector& inputs, const std::vector& output_shape, const OpParams& params = {}); + const BufferDesc& get_output_buffer(size_t node_id) const; + void allocate_buffers(); + size_t get_node_count() const; + + size_t persistent(size_t source_node); + bool is_populated(size_t persistent_node_id) const; + void invalidate_persistent(size_t persistent_node_id); + + std::vector> nodes_; + std::unordered_map node_index_map_; + +private: + static CactusGraph from_serialized(const GraphFile::SerializedGraph& serialized); + size_t next_node_id_; + std::vector> mapped_files_; + std::unordered_map weight_cache_; + std::unordered_map node_to_mapped_file_; + std::vector debug_nodes_; + BufferPool buffer_pool_; + bool prefill_mode_ = false; + + std::unordered_set persistent_node_ids_; + std::unordered_set populated_node_ids_; +}; + + +namespace GraphFile { + struct LoadedNode { + size_t node_id; + std::vector shape; + Precision precision; + size_t byte_size; + }; + + struct GraphHeader { + uint32_t magic; + uint32_t version; + uint32_t node_count; + uint32_t flags = 0; + }; + + struct NodeEntry { + uint32_t index; // serialized node index 0..n-1 + OpType op_type; + std::vector inputs; + std::vector output_shape; + Precision precision; + OpParams params; + }; + + struct SerializedGraph { + GraphHeader header; + std::vector nodes; + std::vector graph_inputs; // IDs of serialized inputs + std::vector graph_outputs; // IDs of serialized outputs + }; + + SerializedGraph load_graph(const std::string& filename); + void save_graph(const CactusGraph& graph, const std::string& filename); + + void save_node(CactusGraph& graph, size_t node_id, const std::string& filename); + + class MappedFile { + public: + MappedFile(const std::string& filename); + ~MappedFile(); + + MappedFile(const MappedFile&) = delete; + MappedFile& operator=(const MappedFile&) = delete; + MappedFile(MappedFile&& other) noexcept; + MappedFile& operator=(MappedFile&& other) noexcept; + + const std::vector& shape() const; + Precision precision() const; + size_t byte_size() const; + + size_t group_size() const { return group_size_; } + size_t num_groups() const { return num_groups_; } + const void* scales_data() const; + + bool is_interleaved() const { return is_interleaved_; } + size_t original_N() const { return original_N_; } + + void* data(); + const void* data() const; + + template + const T* typed_data() const; + + void release_pages(); + void prefetch_pages(); + + private: + int fd_; + void* mapped_data_; + size_t file_size_, data_offset_; + std::vector shape_; + Precision precision_; + size_t byte_size_; + size_t group_size_ = 0; + size_t num_groups_ = 0; + size_t scales_offset_ = 0; + size_t scales_bytes_ = 0; + uint32_t alignment_ = 32; + + bool is_interleaved_ = false; + size_t original_N_ = 0; + + void parse_header(); + void apply_madvise_hints(); + }; +} + +#endif diff --git a/Frameworks/cactus-ios.xcframework/ios-arm64-simulator/cactus.framework/Headers/kernel.h b/Frameworks/cactus-ios.xcframework/ios-arm64-simulator/cactus.framework/Headers/kernel.h new file mode 100644 index 00000000..2c0fdfc3 --- /dev/null +++ b/Frameworks/cactus-ios.xcframework/ios-arm64-simulator/cactus.framework/Headers/kernel.h @@ -0,0 +1,470 @@ +#ifndef KERNEL_H +#define KERNEL_H + +#include +#include + +enum class Precision; + +enum class ScalarOpType { + ADD, + SUBTRACT, + MULTIPLY, + DIVIDE, + ABS, + EXP, + POW, + SQRT, + COS, + SIN, + LOG +}; + +constexpr size_t KV_QUANT_GROUP_SIZE = 32; + +void cactus_add_f16(const __fp16* a, const __fp16* b, __fp16* output, size_t num_elements); +void cactus_add_f16_clipped(const __fp16* a, const __fp16* b, __fp16* output, size_t num_elements); +void cactus_subtract_f16(const __fp16* a, const __fp16* b, __fp16* output, size_t num_elements); +void cactus_multiply_f16(const __fp16* a, const __fp16* b, __fp16* output, size_t num_elements); +void cactus_add_scaled_f16(const __fp16* base, const __fp16* src, __fp16* output, size_t num_elements, float scale); +void cactus_divide_f16(const __fp16* a, const __fp16* b, __fp16* output, size_t num_elements); + +void cactus_add_broadcast_f16(const __fp16* a, const __fp16* b, __fp16* output, + const size_t* a_strides, const size_t* b_strides, + const size_t* output_shape, size_t ndim); +void cactus_subtract_broadcast_f16(const __fp16* a, const __fp16* b, __fp16* output, + const size_t* a_strides, const size_t* b_strides, + const size_t* output_shape, size_t ndim); +void cactus_multiply_broadcast_f16(const __fp16* a, const __fp16* b, __fp16* output, + const size_t* a_strides, const size_t* b_strides, + const size_t* output_shape, size_t ndim); +void cactus_divide_broadcast_f16(const __fp16* a, const __fp16* b, __fp16* output, + const size_t* a_strides, const size_t* b_strides, + const size_t* output_shape, size_t ndim); + +void cactus_scalar_op_f16(const __fp16* input, __fp16* output, size_t num_elements, float scalar_value, ScalarOpType op_type); + +void cactus_gemv_int8(const int8_t* A, float A_scale, + const int8_t* B, const __fp16* B_scales, + __fp16* C, size_t K, size_t N, size_t group_size); + +void cactus_gemm_int8(const int8_t* A, const float* A_scales, + const int8_t* B, const __fp16* B_scales, + __fp16* C, size_t M, size_t K, size_t N, size_t group_size); + +void cactus_matmul_int8(const int8_t* A, const float* A_scales, + const int8_t* B, const __fp16* B_scales, + __fp16* C, size_t M, size_t K, size_t N, size_t group_size); + +void cactus_gemv_int8_i8mm(const int8_t* A, float A_scale, + const int8_t* B, const __fp16* B_scales, + __fp16* C, size_t K, size_t N, size_t group_size); + +void cactus_gemm_int8_i8mm(const int8_t* A, const float* A_scales, + const int8_t* B, const __fp16* B_scales, + __fp16* C, size_t M, size_t K, size_t N, size_t group_size); + +void cactus_gemv_int4(const int8_t* A, float A_scale, + const int8_t* B_packed, const __fp16* B_scales, + __fp16* C, size_t K, size_t N, size_t group_size); + +void cactus_gemm_int4(const int8_t* A, const float* A_scales, + const int8_t* B_packed, const __fp16* B_scales, + __fp16* C, size_t M, size_t K, size_t N, size_t group_size); + +void cactus_matmul_int4(const int8_t* A, const float* A_scales, + const int8_t* B_packed, const __fp16* B_scales, + __fp16* C, size_t M, size_t K, size_t N, size_t group_size); + +void cactus_matmul_integer(Precision precision, + const int8_t* A, const float* A_scales, + const int8_t* B, const __fp16* B_scales, + __fp16* C, size_t M, size_t K, size_t N, size_t group_size); + +void cactus_matmul_f16(const __fp16* a, const __fp16* b_transposed, __fp16* c, + size_t M, size_t K, size_t N); + +void cactus_transpose_2d_f16(const __fp16* source, __fp16* destination, + size_t num_rows, size_t num_cols, size_t start_row, size_t end_row); +void cactus_transpose_f16(const __fp16* source, __fp16* destination, const size_t* shape, + const size_t* permutation, size_t ndim, size_t start_idx, size_t end_idx); + +double cactus_sum_all_f16(const __fp16* data, size_t num_elements); +void cactus_sum_axis_f16(const __fp16* input, __fp16* output, size_t outer_size, size_t axis_size, size_t inner_size); + +double cactus_mean_all_f16(const __fp16* data, size_t num_elements); +void cactus_mean_axis_f16(const __fp16* input, __fp16* output, size_t outer_size, size_t axis_size, size_t inner_size); + +double cactus_variance_all_f16(const __fp16* data, size_t num_elements); +void cactus_variance_axis_f16(const __fp16* input, __fp16* output, size_t outer_size, size_t axis_size, size_t inner_size); + +__fp16 cactus_min_all_f16(const __fp16* data, size_t num_elements); +void cactus_min_axis_f16(const __fp16* input, __fp16* output, size_t outer_size, size_t axis_size, size_t inner_size); + +__fp16 cactus_max_all_f16(const __fp16* data, size_t num_elements); +void cactus_max_axis_f16(const __fp16* input, __fp16* output, size_t outer_size, size_t axis_size, size_t inner_size); + +void cactus_rms_norm_f16(const __fp16* input, const __fp16* weight, __fp16* output, + size_t batch_size, size_t dims, float eps); + +void cactus_layer_norm_f16(const __fp16* input, const __fp16* weight, const __fp16* bias, + __fp16* output, size_t batch_size, size_t dims, float eps); + +void cactus_rope_f16(const __fp16* input, __fp16* output, size_t batch_size, size_t seq_len, + size_t num_heads, size_t head_dim, size_t start_pos, float theta); + +void cactus_gpt_j_rope_f16(const __fp16* input, __fp16* output, size_t batch_size, size_t seq_len, + size_t num_heads, size_t head_dim, size_t rot_dim, size_t start_pos, float theta); + +void cactus_softmax_f16(const __fp16* input, __fp16* output, size_t batch_size, + size_t seq_len, size_t vocab_size); + +void cactus_relu_f16(const __fp16* input, __fp16* output, size_t num_elements); + +void cactus_leaky_relu_f16(const __fp16* input, __fp16* output, size_t num_elements, float negative_slope); + +void cactus_silu_f16(const __fp16* input, __fp16* output, size_t num_elements); + +void cactus_gelu_f16(const __fp16* input, __fp16* output, size_t num_elements); + +void cactus_gelu_f16_erf(const __fp16* input, __fp16* output, size_t num_elements); + +void cactus_sigmoid_f16(const __fp16* input, __fp16* output, size_t num_elements); + +void cactus_tanh_f16(const __fp16* input, __fp16* output, size_t num_elements); + +void cactus_glu_f16( + const __fp16* input, + __fp16* output, + size_t outer_size, + size_t split_size, + size_t inner_size +); + +void cactus_glu_f32( + const float* input, + float* output, + size_t outer_size, + size_t split_size, + size_t inner_size +); + +void cactus_batchnorm_f16( + const __fp16* input, + const float* weight, + const float* bias, + const float* running_mean, + const float* running_var, + __fp16* output, + size_t outer_size, + size_t channels, + size_t inner_size, + float epsilon +); + +void cactus_batchnorm_f32( + const float* input, + const float* weight, + const float* bias, + const float* running_mean, + const float* running_var, + float* output, + size_t outer_size, + size_t channels, + size_t inner_size, + float epsilon +); + +void cactus_attention_f16(const __fp16* queries, const __fp16* keys, const __fp16* values, __fp16* output, + size_t batch_size, size_t seq_len, size_t kv_seq_len, size_t num_q_heads, size_t num_kv_heads, + size_t head_dim, float scale, const __fp16* mask, size_t position_offset = 0, size_t window_size = 0, + bool is_causal = true, bool mask_is_additive = false, bool mask_per_head = false, + size_t v_head_dim = 0, float logit_cap = 0.0f); + +void cactus_attention_hybrid_int8_fp16( + const __fp16* queries, + const int8_t* keys_cached, + const int8_t* values_cached, + const float* k_scales, + const float* v_scales, + const __fp16* keys_new, + const __fp16* values_new, + __fp16* output, + size_t batch_size, size_t seq_len, size_t cache_len, size_t new_len, + size_t num_q_heads, size_t num_kv_heads, size_t head_dim, + float scale, size_t position_offset = 0, bool is_causal = true, size_t window_size = 0, + size_t group_size = KV_QUANT_GROUP_SIZE, size_t v_head_dim = 0); + +void cactus_gated_deltanet_decode_f16( + const __fp16* q_data, + const __fp16* k_data, + const __fp16* v_data, + const __fp16* g_data, + const __fp16* b_data, + const __fp16* s_data, + __fp16* out, + size_t B, + size_t Hq, + size_t Hv, + size_t K, + size_t V, + float scale); + +void cactus_gated_deltanet_prefill_f16( + const __fp16* q_data, + const __fp16* k_data, + const __fp16* v_data, + const __fp16* g_data, + const __fp16* b_data, + const __fp16* s_data, + __fp16* out, + size_t B, + size_t T, + size_t Hq, + size_t Hv, + size_t K, + size_t V, + size_t requested_chunk_size, + float scale); + +void cactus_conv1d_causal_depthwise_f16( + const __fp16* input, + const __fp16* weight, + __fp16* output, + size_t N, + size_t L, + size_t C, + size_t K, + size_t dilation); + +void cactus_conv1d_f16_k3( + const __fp16* input, + const __fp16* weight, + __fp16* output, + size_t N, + size_t L, + size_t C_in, + size_t C_out, + size_t stride +); + +void cactus_conv1d_f16( + const __fp16* input, + const __fp16* weight, + const __fp16* bias, + __fp16* output, + size_t N, + size_t L, + size_t C_in, + size_t C_out, + size_t K, + size_t stride +); + +void cactus_stft_f16( + const __fp16* input, + const __fp16* weight, + __fp16* output, + size_t N, size_t L, + size_t C_in, size_t C_out, + size_t K, size_t stride, + size_t num_fft_bins +); + +void cactus_conv1d_f16_k7s3_oc8( + const __fp16* input, + const __fp16* Wpack, + const __fp16* bias, + __fp16* output, + size_t N, + size_t L, + size_t C_in, + size_t C_out +); + +void cactus_conv1d_same_depthwise_f16_k9( + const __fp16* input, + const __fp16* weight, + const __fp16* bias, + __fp16* output, + size_t N, + size_t L, + size_t C +); + +void cactus_conv2d_f16_k3s1p1_nchw( + const __fp16* input, + const __fp16* weight, + const __fp16* bias, + __fp16* output, + size_t N, + size_t C_in, + size_t H, + size_t W, + size_t C_out +); + +void cactus_conv2d_f16_k3s2p1_nchw( + const __fp16* input, + const __fp16* weight, + const __fp16* bias, + __fp16* output, + size_t N, + size_t C_in, + size_t H, + size_t W, + size_t C_out +); + +void cactus_conv2d_depthwise_f16_k3s2p1_nchw( + const __fp16* input, + const __fp16* weight, + const __fp16* bias, + __fp16* output, + size_t N, + size_t C, + size_t H, + size_t W +); + +void cactus_conv2d_pointwise_f16_1x1_nchw_gemm( + const __fp16* input, + const __fp16* weight, + const __fp16* bias, + __fp16* output, + size_t N, + size_t C_in, + size_t H, + size_t W, + size_t C_out +); + +void cactus_conv1d_pointwise_f16_gemm( + const __fp16* input, + const __fp16* weight, + const __fp16* bias, + __fp16* output, + size_t N, + size_t L, + size_t C_in, + size_t C_out +); + +void cactus_bilinear_interpolation_f16(const __fp16* input, __fp16* output, size_t src_height, size_t src_width, size_t embed_dim, + size_t dst_height, size_t dst_width, bool align_corners = true); + +void cactus_sample_f32(const float* logits, uint32_t* output, size_t vocab_size, + float temperature, float top_p, size_t top_k, size_t random_seed, + const float* bias_values = nullptr, const uint32_t* bias_indices = nullptr, + size_t bias_count = 0); +void cactus_sample_f16(const __fp16* logits, uint32_t* output, size_t vocab_size, + float temperature, float top_p, size_t top_k, size_t random_seed, + const float* bias_values = nullptr, const uint32_t* bias_indices = nullptr, + size_t bias_count = 0); + +void cactus_sample_f32_ex(const float* logits, uint32_t* output, size_t vocab_size, + float temperature, float top_p, float min_p, float repetition_penalty, + size_t top_k, size_t random_seed, + const float* bias_values = nullptr, const uint32_t* bias_indices = nullptr, + size_t bias_count = 0); +void cactus_sample_f16_ex(const __fp16* logits, uint32_t* output, size_t vocab_size, + float temperature, float top_p, float min_p, float repetition_penalty, + size_t top_k, size_t random_seed, + const float* bias_values = nullptr, const uint32_t* bias_indices = nullptr, + size_t bias_count = 0); + +void cactus_concat_f16(const __fp16* input1, const __fp16* input2, __fp16* output, + const size_t* shape1, const size_t* shape2, const size_t* output_shape, + size_t ndims, int axis); +void cactus_cat_f16(const __fp16** inputs, __fp16* output, const size_t** input_shapes, + const size_t* output_shape, size_t num_inputs, size_t rank, int axis); + +void cactus_int8_to_fp32(const int8_t* src, float* dst, size_t count, float scale = 1.0f); +void cactus_fp32_to_int8(const float* src, int8_t* dst, size_t count, float scale = 1.0f); +void cactus_fp16_to_fp32(const __fp16* src, float* dst, size_t count); +void cactus_fp32_to_fp16(const float* src, __fp16* dst, size_t count); +void cactus_int8_to_fp16(const int8_t* src, __fp16* dst, size_t count, float scale = 1.0f); +void cactus_fp16_to_int8(const __fp16* src, int8_t* dst, size_t count, float scale = 1.0f); +float cactus_fp16_max_abs(const __fp16* src, size_t count); + +void cactus_quantize_kv_fp16_to_int8( + const __fp16* src, + int8_t* dst, + float* scales, + size_t seq_len, size_t kv_heads, size_t head_dim, + size_t group_size = KV_QUANT_GROUP_SIZE); + +inline size_t kv_scales_count(size_t seq_len, size_t kv_heads, size_t head_dim, size_t group_size = KV_QUANT_GROUP_SIZE) { + size_t num_groups = (head_dim + group_size - 1) / group_size; + return seq_len * kv_heads * num_groups; +} + +void cactus_unpack_int4_to_int8(const uint8_t* packed, int8_t* unpacked, size_t unpacked_count); + +void cactus_gaussian_topk_f16( + const __fp16* input, + __fp16* output, + size_t rows, + size_t cols, + float ppf); + +void cactus_altup_predict_f16( + const __fp16* coefs, + const __fp16* const* streams, + __fp16* output, + size_t n, + size_t seq_len, + size_t hidden_dim); + +void cactus_altup_correct_f16( + const __fp16* coefs, + const __fp16* innovation, + const __fp16* const* predictions, + __fp16* output, + size_t n, + size_t seq_len, + size_t hidden_dim); + +void cactus_lstm_cell_f16( + const __fp16* x_input, + const __fp16* h_prev, + const __fp16* c_prev, + const __fp16* weight_ih, + const __fp16* weight_hh, + const __fp16* bias_ih, + const __fp16* bias_hh, + __fp16* h_new, + __fp16* c_new, + size_t batch_size, + size_t input_size, + size_t hidden_size +); + +void cactus_bilstm_sequence_f16( + const __fp16* input, + const __fp16* weight_ih_fwd, + const __fp16* weight_hh_fwd, + const __fp16* bias_ih_fwd, + const __fp16* bias_hh_fwd, + const __fp16* weight_ih_bwd, + const __fp16* weight_hh_bwd, + const __fp16* bias_ih_bwd, + const __fp16* bias_hh_bwd, + __fp16* output, + size_t batch_size, + size_t seq_len, + size_t input_size, + size_t hidden_size +); + +void cactus_maxpool1d_f16( + const __fp16* input, + __fp16* output, + size_t batch_size, + size_t channels, + size_t input_length, + size_t kernel_size, + size_t stride +); + +#endif diff --git a/Frameworks/cactus-ios.xcframework/ios-arm64-simulator/cactus.framework/Headers/kernel_utils.h b/Frameworks/cactus-ios.xcframework/ios-arm64-simulator/cactus.framework/Headers/kernel_utils.h new file mode 100644 index 00000000..568e3f3c --- /dev/null +++ b/Frameworks/cactus-ios.xcframework/ios-arm64-simulator/cactus.framework/Headers/kernel_utils.h @@ -0,0 +1,710 @@ +#ifndef KERNEL_UTILS_H +#define KERNEL_UTILS_H + +#include +#if defined(__APPLE__) +#include +#include +#endif +#if defined(__ANDROID__) +#include +#include +#include +#include +#endif +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +constexpr size_t NEON_VECTOR_SIZE = 16; +constexpr size_t STREAMING_STORE_THRESHOLD = 32768; + +inline void stream_store_f16x8(__fp16* dst, float16x8_t val) { +#if defined(__aarch64__) + float16x4_t lo = vget_low_f16(val); + float16x4_t hi = vget_high_f16(val); + __asm__ __volatile__( + "stnp %d0, %d1, [%2]" + : + : "w"(lo), "w"(hi), "r"(dst) + : "memory" + ); +#else + vst1q_f16(dst, val); +#endif +} + +inline bool cpu_has_i8mm() { +#if defined(__aarch64__) + static std::once_flag once; + static bool has = false; + + std::call_once(once, []() { +#if defined(__APPLE__) + int ret = 0; + size_t size = sizeof(ret); + if (sysctlbyname("hw.optional.arm.FEAT_I8MM", &ret, &size, nullptr, 0) == 0) { + has = (ret == 1); + } +#elif defined(__ANDROID__) + unsigned long hwcap2 = getauxval(AT_HWCAP2); + #ifndef HWCAP2_I8MM + #define HWCAP2_I8MM (1 << 13) + #endif + has = (hwcap2 & HWCAP2_I8MM) != 0; +#endif + }); + + return has; +#else + return false; +#endif +} + +inline bool cpu_has_sme2() { +#if defined(__aarch64__) + static std::once_flag once; + static bool has = false; + + std::call_once(once, []() { + +#if defined(__APPLE__) + int ret = 0; + size_t size = sizeof(ret); + if (sysctlbyname("hw.optional.arm.FEAT_SME2", &ret, &size, nullptr, 0) == 0) { + has = ret == 1; + } + +#elif defined(__ANDROID__) + unsigned long hwcap2 = getauxval(AT_HWCAP2); +#ifdef HWCAP2_SME2 + has = (hwcap2 & HWCAP2_SME2) != 0; +#endif + +#endif + }); + + return has; +#else + return false; +#endif +} + +inline float32x4_t fast_exp_f32x4(float32x4_t x) { + const float32x4_t log2e = vdupq_n_f32(1.4426950408889634f); + const float32x4_t ln2 = vdupq_n_f32(0.6931471805599453f); + + const float32x4_t c0 = vdupq_n_f32(1.0f); + const float32x4_t c1 = vdupq_n_f32(0.6931471805599453f); + const float32x4_t c2 = vdupq_n_f32(0.2402265069591007f); + const float32x4_t c3 = vdupq_n_f32(0.05550410866482158f); + const float32x4_t c4 = vdupq_n_f32(0.009618129842071803f); + + x = vmaxq_f32(x, vdupq_n_f32(-87.0f)); + x = vminq_f32(x, vdupq_n_f32(87.0f)); + + float32x4_t z = vmulq_f32(x, log2e); + + int32x4_t zi = vcvtq_s32_f32(z); + float32x4_t zf = vsubq_f32(z, vcvtq_f32_s32(zi)); + + uint32x4_t neg_mask = vcltq_f32(zf, vdupq_n_f32(0.0f)); + zi = vsubq_s32(zi, vandq_s32(vreinterpretq_s32_u32(neg_mask), vdupq_n_s32(1))); + zf = vaddq_f32(zf, vreinterpretq_f32_u32(vandq_u32(neg_mask, vreinterpretq_u32_f32(vdupq_n_f32(1.0f))))); + + float32x4_t zf_ln2 = vmulq_f32(zf, ln2); + float32x4_t p = c4; + p = vfmaq_f32(c3, p, zf_ln2); + p = vfmaq_f32(c2, p, zf_ln2); + p = vfmaq_f32(c1, p, zf_ln2); + p = vfmaq_f32(c0, p, zf_ln2); + + int32x4_t exp_bits = vshlq_n_s32(vaddq_s32(zi, vdupq_n_s32(127)), 23); + float32x4_t scale = vreinterpretq_f32_s32(exp_bits); + + return vmulq_f32(p, scale); +} + +// Cephes-style 13/6 rational tanh approximation (same coefficients as Eigen). +// Constants are stored as static splatted arrays so the compiler emits a single +// pc-relative `ldr q` per load. +alignas(16) inline constexpr float kFastTanhAlpha[7][4] = { + { 4.89352455891786e-03f, 4.89352455891786e-03f, 4.89352455891786e-03f, 4.89352455891786e-03f }, + { 6.37261928875436e-04f, 6.37261928875436e-04f, 6.37261928875436e-04f, 6.37261928875436e-04f }, + { 1.48572235717979e-05f, 1.48572235717979e-05f, 1.48572235717979e-05f, 1.48572235717979e-05f }, + { 5.12229709037114e-08f, 5.12229709037114e-08f, 5.12229709037114e-08f, 5.12229709037114e-08f }, + {-8.60467152213735e-11f,-8.60467152213735e-11f,-8.60467152213735e-11f,-8.60467152213735e-11f }, + { 2.00018790482477e-13f, 2.00018790482477e-13f, 2.00018790482477e-13f, 2.00018790482477e-13f }, + {-2.76076847742355e-16f,-2.76076847742355e-16f,-2.76076847742355e-16f,-2.76076847742355e-16f }, +}; +alignas(16) inline constexpr float kFastTanhBeta[4][4] = { + { 4.89352518554385e-03f, 4.89352518554385e-03f, 4.89352518554385e-03f, 4.89352518554385e-03f }, + { 2.26843463243900e-03f, 2.26843463243900e-03f, 2.26843463243900e-03f, 2.26843463243900e-03f }, + { 1.18534705686654e-04f, 1.18534705686654e-04f, 1.18534705686654e-04f, 1.18534705686654e-04f }, + { 1.19825839466702e-06f, 1.19825839466702e-06f, 1.19825839466702e-06f, 1.19825839466702e-06f }, +}; +alignas(16) inline constexpr float kFastTanhClampHi[4] = { 9.0f, 9.0f, 9.0f, 9.0f }; +alignas(16) inline constexpr float kFastTanhClampLo[4] = {-9.0f,-9.0f,-9.0f,-9.0f }; + +inline float32x4_t fast_tanh_f32x4(float32x4_t x) { + x = vmaxq_f32(vld1q_f32(kFastTanhClampLo), vminq_f32(vld1q_f32(kFastTanhClampHi), x)); + float32x4_t x2 = vmulq_f32(x, x); + float32x4_t p = vfmaq_f32(vld1q_f32(kFastTanhAlpha[5]), vld1q_f32(kFastTanhAlpha[6]), x2); + p = vfmaq_f32(vld1q_f32(kFastTanhAlpha[4]), p, x2); + p = vfmaq_f32(vld1q_f32(kFastTanhAlpha[3]), p, x2); + p = vfmaq_f32(vld1q_f32(kFastTanhAlpha[2]), p, x2); + p = vfmaq_f32(vld1q_f32(kFastTanhAlpha[1]), p, x2); + p = vfmaq_f32(vld1q_f32(kFastTanhAlpha[0]), p, x2); + p = vmulq_f32(p, x); + float32x4_t q = vfmaq_f32(vld1q_f32(kFastTanhBeta[2]), vld1q_f32(kFastTanhBeta[3]), x2); + q = vfmaq_f32(vld1q_f32(kFastTanhBeta[1]), q, x2); + q = vfmaq_f32(vld1q_f32(kFastTanhBeta[0]), q, x2); + return vdivq_f32(p, q); +} + +constexpr size_t SIMD_F16_WIDTH = 8; + +inline size_t simd_align(size_t count, size_t width = SIMD_F16_WIDTH) { + return (count / width) * width; +} + +inline void f16x8_split_f32(float16x8_t v, float32x4_t& lo, float32x4_t& hi) { + lo = vcvt_f32_f16(vget_low_f16(v)); + hi = vcvt_f32_f16(vget_high_f16(v)); +} + +inline float16x8_t f32_merge_f16(float32x4_t lo, float32x4_t hi) { + return vcombine_f16(vcvt_f16_f32(lo), vcvt_f16_f32(hi)); +} + +inline float32x4_t fast_sigmoid_f32x4(float32x4_t x) { + const float32x4_t one = vdupq_n_f32(1.0f); + return vdivq_f32(one, vaddq_f32(one, fast_exp_f32x4(vnegq_f32(x)))); +} + +template +inline float16x8_t apply_f32_op_on_f16x8(float16x8_t v, F32x4Op op) { + float32x4_t lo, hi; + f16x8_split_f32(v, lo, hi); + return f32_merge_f16(op(lo), op(hi)); +} + +inline void unpack_int4_as_int8x16x2(const uint8_t* ptr, int8x16_t& high_decoded, int8x16_t& low_decoded) { + int8x16_t packed = vreinterpretq_s8_u8(vld1q_u8(ptr)); + high_decoded = vshrq_n_s8(packed, 4); + low_decoded = vshrq_n_s8(vshlq_n_s8(packed, 4), 4); +} + +namespace CactusThreading { + +#if defined(__ANDROID__) + struct CoreTopology { + std::vector performance_cores; + std::vector all_cores; + + static CoreTopology& get() { + static CoreTopology topo = detect(); + return topo; + } + + private: + static int read_sysfs_int(const char* path) { + std::ifstream f(path); + if (!f.is_open()) return -1; + int val = -1; + f >> val; + return val; + } + + static CoreTopology detect() { + CoreTopology topo; + constexpr int MAX_CPUS = 16; + std::vector> core_caps; + + for (int i = 0; i < MAX_CPUS; ++i) { + char path[128]; + + snprintf(path, sizeof(path), + "/sys/devices/system/cpu/cpu%d/cpu_capacity", i); + int cap = read_sysfs_int(path); + if (cap > 0) { + core_caps.push_back({i, cap}); + topo.all_cores.push_back(i); + continue; + } + + snprintf(path, sizeof(path), + "/sys/devices/system/cpu/cpu%d/cpufreq/cpuinfo_max_freq", i); + int freq = read_sysfs_int(path); + if (freq > 0) { + core_caps.push_back({i, freq}); + topo.all_cores.push_back(i); + } + } + + if (core_caps.empty()) return topo; + + int max_cap = 0; + for (auto& [id, cap] : core_caps) { + max_cap = std::max(max_cap, cap); + } + + int threshold = static_cast(max_cap * 0.70); + for (auto& [id, cap] : core_caps) { + if (cap >= threshold) { + topo.performance_cores.push_back(id); + } + } + + return topo; + } + }; + + inline bool pin_current_thread_to_cores(const std::vector& cores) { + if (cores.empty()) return false; + cpu_set_t mask; + CPU_ZERO(&mask); + for (int core : cores) { + CPU_SET(core, &mask); + } + return sched_setaffinity(0, sizeof(mask), &mask) == 0; + } +#endif + + class ThreadPool { + private: + static constexpr size_t MAX_WORKERS = 16; + + std::vector workers; + std::deque> tasks; + + std::mutex mutex; + std::condition_variable work_available; + std::condition_variable work_done; + + bool stop{false}; + std::atomic pending_tasks{0}; + size_t num_workers_; + + void worker_thread() { + while (true) { + std::function task; + { + std::unique_lock lock(mutex); + work_available.wait(lock, [this] { + return stop || !tasks.empty(); + }); + + if (stop && tasks.empty()) { + return; + } + + task = std::move(tasks.front()); + tasks.pop_front(); + } + + task(); + + if (pending_tasks.fetch_sub(1, std::memory_order_acq_rel) == 1) { + std::lock_guard lock(mutex); + work_done.notify_one(); + } + } + } + + public: + explicit ThreadPool(size_t num_threads = std::thread::hardware_concurrency()) + : stop(false), pending_tasks(0) { + num_workers_ = std::min(num_threads, MAX_WORKERS); + if (num_workers_ == 0) num_workers_ = 1; + +#if defined(__ANDROID__) + auto& topo = CoreTopology::get(); + if (!topo.performance_cores.empty()) { + num_workers_ = std::min(num_workers_, topo.performance_cores.size()); + } +#endif + + workers.reserve(num_workers_); + for (size_t i = 0; i < num_workers_; ++i) { + workers.emplace_back([this]() { +#if defined(__ANDROID__) + auto& perf = CoreTopology::get().performance_cores; + if (!perf.empty()) { + pin_current_thread_to_cores(perf); + } +#endif + worker_thread(); + }); + } + } + + ~ThreadPool() { + { + std::lock_guard lock(mutex); + stop = true; + } + work_available.notify_all(); + for (auto& worker : workers) { + if (worker.joinable()) { + worker.join(); + } + } + } + + template + auto enqueue(F&& f) -> std::future { + using return_type = decltype(f()); + + auto task = std::make_shared>( + std::forward(f) + ); + + std::future res = task->get_future(); + + { + std::lock_guard lock(mutex); + pending_tasks.fetch_add(1, std::memory_order_relaxed); + tasks.emplace_back([task](){ (*task)(); }); + } + work_available.notify_one(); + + return res; + } + + template + void enqueue_batch(size_t total_work, F task_func) { + if (total_work == 0) return; + + const size_t num_tasks = std::min(num_workers_, total_work); + const size_t per_worker = total_work / num_tasks; + const size_t remainder = total_work % num_tasks; + + { + std::lock_guard lock(mutex); + pending_tasks.fetch_add(num_tasks, std::memory_order_relaxed); + + for (size_t w = 0; w < num_tasks; ++w) { + size_t start = w * per_worker + std::min(w, remainder); + size_t end = start + per_worker + (w < remainder ? 1 : 0); + tasks.emplace_back([=]() { task_func(start, end); }); + } + } + work_available.notify_all(); + } + + void wait_all() { + std::unique_lock lock(mutex); + work_done.wait(lock, [this] { + return pending_tasks.load(std::memory_order_acquire) == 0; + }); + } + + template + void enqueue_n_threads(size_t total_work, size_t num_threads, F task_func) { + if (total_work == 0 || num_threads == 0) return; + + num_threads = std::min(num_threads, std::min(num_workers_, total_work)); + const size_t per_thread = total_work / num_threads; + const size_t remainder = total_work % num_threads; + + { + std::lock_guard lock(mutex); + pending_tasks.fetch_add(num_threads, std::memory_order_relaxed); + + for (size_t t = 0; t < num_threads; ++t) { + size_t start = t * per_thread + std::min(t, remainder); + size_t end = start + per_thread + (t < remainder ? 1 : 0); + tasks.emplace_back([=]() { task_func(start, end); }); + } + } + work_available.notify_all(); + } + + size_t num_workers() const { return num_workers_; } + }; + + inline ThreadPool& get_thread_pool() { + static ThreadPool pool; + return pool; + } + + struct ParallelConfig { + size_t min_work_gate; + size_t work_per_thread; + + constexpr ParallelConfig(size_t gate, size_t per_thread) + : min_work_gate(gate), work_per_thread(per_thread) {} + }; + + inline size_t get_optimal_thread_count(size_t total_work, ParallelConfig config) { + if (total_work < config.min_work_gate) return 1; + + size_t pool_size = get_thread_pool().num_workers(); + size_t num_threads = (total_work + config.work_per_thread - 1) / config.work_per_thread; + return std::min(pool_size, std::max(static_cast(1), num_threads)); + } + + struct Thresholds { + #if defined(__ANDROID__) + static constexpr ParallelConfig ATTENTION{64, 32}; + static constexpr ParallelConfig ELEMENT_WISE{5000, 2500}; + static constexpr ParallelConfig AXIS_REDUCE{1000, 500}; + static constexpr ParallelConfig ALL_REDUCE{10000, 5000}; + static constexpr ParallelConfig SCALAR_BASIC{30000, 15000}; + static constexpr ParallelConfig SCALAR_EXPENSIVE{10000, 5000}; + #else // Apple + static constexpr ParallelConfig ATTENTION{32, 16}; + static constexpr ParallelConfig ELEMENT_WISE{5000, 2500}; + static constexpr ParallelConfig AXIS_REDUCE{1000, 500}; + static constexpr ParallelConfig ALL_REDUCE{10000, 5000}; + static constexpr ParallelConfig SCALAR_BASIC{5000, 2500}; + static constexpr ParallelConfig SCALAR_EXPENSIVE{2500, 1250}; + #endif + }; + + struct GemmThreading { + #if defined(__ANDROID__) + static size_t get_num_threads(size_t M, size_t pool_size) { + if (M <= 1) return 1; + return pool_size; + } + static size_t get_gemv_threads(size_t /*N_blocks*/, size_t /*pool_size*/) { + return 1; + } + #elif defined(__APPLE__) && TARGET_OS_IPHONE + static constexpr size_t GEMV_MIN_N_BLOCKS = 512; + static size_t get_num_threads(size_t M, size_t pool_size) { + if (M <= 1) return std::min(pool_size, static_cast(2)); + return pool_size; + } + static size_t get_gemv_threads(size_t N_blocks, size_t pool_size) { + if (N_blocks < GEMV_MIN_N_BLOCKS) return 1; + return std::min(pool_size, static_cast(3)); + } + #else + static constexpr size_t GEMV_MIN_N_BLOCKS = 256; + static size_t get_num_threads(size_t M, size_t pool_size) { + if (M <= 1) return std::min(pool_size, static_cast(4)); + return pool_size; + } + static size_t get_gemv_threads(size_t N_blocks, size_t pool_size) { + if (N_blocks < GEMV_MIN_N_BLOCKS) return 1; + if (N_blocks < 512) return std::min(pool_size, static_cast(2)); + return std::min(pool_size, static_cast(5)); + } + #endif + }; + + inline size_t& get_gemm_thread_override() { + static size_t override_threads = 0; + return override_threads; + } + + inline void set_gemm_threads(size_t num_threads) { + get_gemm_thread_override() = num_threads; + } + + inline void reset_gemm_threads() { + get_gemm_thread_override() = 0; + } + + class TaskHandle { + private: + std::vector> futures_; + bool auto_wait_; + + public: + TaskHandle(bool auto_wait = true) : auto_wait_(auto_wait) {} + + ~TaskHandle() { + if (auto_wait_) { + wait(); + } + } + + TaskHandle(TaskHandle&&) = default; + TaskHandle& operator=(TaskHandle&&) = default; + TaskHandle(const TaskHandle&) = delete; + TaskHandle& operator=(const TaskHandle&) = delete; + + void add_future(std::future&& f) { + futures_.push_back(std::move(f)); + } + + void wait() { + for (auto& f : futures_) { + if (f.valid()) { + f.wait(); + } + } + futures_.clear(); + } + + bool is_ready() const { + for (const auto& f : futures_) { + if (f.valid() && f.wait_for(std::chrono::seconds(0)) != std::future_status::ready) { + return false; + } + } + return true; + } + + size_t task_count() const { return futures_.size(); } + }; + + template + TaskHandle parallel_for(size_t total_work, ParallelConfig config, WorkFunc work_func, bool wait = true) { + const size_t num_threads = get_optimal_thread_count(total_work, config); + TaskHandle handle(!wait); + + if (num_threads == 1) { + if (wait) { + work_func(0, total_work); + return handle; + } + auto& pool = get_thread_pool(); + handle.add_future(pool.enqueue([work_func, total_work]() { + work_func(0, total_work); + })); + return handle; + } + + auto& pool = get_thread_pool(); + const size_t work_per_thread = total_work / num_threads; + + for (size_t t = 0; t < num_threads; ++t) { + handle.add_future(pool.enqueue([work_func, t, num_threads, work_per_thread, total_work]() { + const size_t start_idx = t * work_per_thread; + const size_t end_idx = (t == num_threads - 1) ? total_work : (t + 1) * work_per_thread; + work_func(start_idx, end_idx); + })); + } + + if (wait) { + handle.wait(); + } + return handle; + } + + template + void parallel_for_2d(size_t outer_size, size_t inner_size, ParallelConfig config, WorkFunc work_func) { + const size_t total_work = outer_size * inner_size; + parallel_for(total_work, config, [&](size_t start_idx, size_t end_idx) { + for (size_t work_idx = start_idx; work_idx < end_idx; ++work_idx) { + const size_t outer = work_idx / inner_size; + const size_t inner = work_idx % inner_size; + work_func(outer, inner); + } + }); + } + + template + ResultType parallel_reduce(size_t total_work, ParallelConfig config, + WorkFunc work_func, ResultType init_value, CombineFunc combine_func) { + const size_t num_threads = get_optimal_thread_count(total_work, config); + + if (num_threads == 1) { + return work_func(0, total_work); + } + + auto& pool = get_thread_pool(); + std::vector> futures; + std::vector partial_results(num_threads, init_value); + const size_t work_per_thread = total_work / num_threads; + + for (size_t t = 0; t < num_threads; ++t) { + futures.push_back(pool.enqueue([work_func, t, num_threads, work_per_thread, total_work]() -> ResultType { + const size_t start_idx = t * work_per_thread; + const size_t end_idx = (t == num_threads - 1) ? total_work : (t + 1) * work_per_thread; + return work_func(start_idx, end_idx); + })); + } + + ResultType result = init_value; + for (auto& future : futures) { + result = combine_func(result, future.get()); + } + return result; + } + + template + void parallel_gemm_tiles(size_t M, size_t total_tiles, WorkFunc work_func) { + auto& pool = get_thread_pool(); + + size_t override = get_gemm_thread_override(); + size_t num_threads = (override > 0) ? override : GemmThreading::get_num_threads(M, pool.num_workers()); + num_threads = std::min(num_threads, total_tiles); + + if (num_threads <= 1) { + work_func(0, total_tiles); + return; + } + + pool.enqueue_n_threads(total_tiles, num_threads, work_func); + pool.wait_all(); + } + +} + +template +void elementwise_op_f16(const __fp16* input, __fp16* output, size_t num_elements, + bool use_streaming, CactusThreading::ParallelConfig config, + SimdOp simd_op, ScalarOp scalar_op, size_t unroll = 4) { + CactusThreading::parallel_for(num_elements, config, + [&](size_t start, size_t end) { + const size_t n = end - start; + const size_t vec_end = start + simd_align(n); + + if (use_streaming && unroll >= 4) { + const size_t unrolled_end = start + simd_align(n, SIMD_F16_WIDTH * 4); + for (size_t i = start; i < unrolled_end; i += SIMD_F16_WIDTH * 4) { + __builtin_prefetch(&input[i + 256], 0, 0); + float16x8_t v0 = simd_op(vld1q_f16(&input[i])); + float16x8_t v1 = simd_op(vld1q_f16(&input[i + 8])); + float16x8_t v2 = simd_op(vld1q_f16(&input[i + 16])); + float16x8_t v3 = simd_op(vld1q_f16(&input[i + 24])); + stream_store_f16x8(&output[i], v0); + stream_store_f16x8(&output[i + 8], v1); + stream_store_f16x8(&output[i + 16], v2); + stream_store_f16x8(&output[i + 24], v3); + } + for (size_t i = unrolled_end; i < vec_end; i += SIMD_F16_WIDTH) { + stream_store_f16x8(&output[i], simd_op(vld1q_f16(&input[i]))); + } + } else if (use_streaming && unroll >= 2) { + const size_t unrolled_end = start + simd_align(n, SIMD_F16_WIDTH * 2); + for (size_t i = start; i < unrolled_end; i += SIMD_F16_WIDTH * 2) { + __builtin_prefetch(&input[i + 128], 0, 0); + float16x8_t v0 = simd_op(vld1q_f16(&input[i])); + float16x8_t v1 = simd_op(vld1q_f16(&input[i + 8])); + stream_store_f16x8(&output[i], v0); + stream_store_f16x8(&output[i + 8], v1); + } + for (size_t i = unrolled_end; i < vec_end; i += SIMD_F16_WIDTH) { + stream_store_f16x8(&output[i], simd_op(vld1q_f16(&input[i]))); + } + } else { + for (size_t i = start; i < vec_end; i += SIMD_F16_WIDTH) { + vst1q_f16(&output[i], simd_op(vld1q_f16(&input[i]))); + } + } + for (size_t i = vec_end; i < end; ++i) { + output[i] = scalar_op(input[i]); + } + }); +} + +#endif // KERNEL_UTILS_H diff --git a/Frameworks/cactus-ios.xcframework/ios-arm64-simulator/cactus.framework/Info.plist b/Frameworks/cactus-ios.xcframework/ios-arm64-simulator/cactus.framework/Info.plist new file mode 100644 index 00000000..a54f4e3d Binary files /dev/null and b/Frameworks/cactus-ios.xcframework/ios-arm64-simulator/cactus.framework/Info.plist differ diff --git a/Frameworks/cactus-ios.xcframework/ios-arm64-simulator/cactus.framework/Modules/module.modulemap b/Frameworks/cactus-ios.xcframework/ios-arm64-simulator/cactus.framework/Modules/module.modulemap new file mode 100644 index 00000000..daa01413 --- /dev/null +++ b/Frameworks/cactus-ios.xcframework/ios-arm64-simulator/cactus.framework/Modules/module.modulemap @@ -0,0 +1,5 @@ +framework module cactus { + umbrella header "cactus_ffi.h" + export * + module * { export * } +} diff --git a/Frameworks/cactus-ios.xcframework/ios-arm64-simulator/cactus.framework/_CodeSignature/CodeResources b/Frameworks/cactus-ios.xcframework/ios-arm64-simulator/cactus.framework/_CodeSignature/CodeResources new file mode 100644 index 00000000..b513a698 --- /dev/null +++ b/Frameworks/cactus-ios.xcframework/ios-arm64-simulator/cactus.framework/_CodeSignature/CodeResources @@ -0,0 +1,101 @@ + + + + + files + + Info.plist + + O5yRuwrCovvaHA/bIkWiOCqSCug= + + + files2 + + rules + + ^.* + + ^.*\.lproj/ + + optional + + weight + 1000 + + ^.*\.lproj/locversion.plist$ + + omit + + weight + 1100 + + ^Base\.lproj/ + + weight + 1010 + + ^version.plist$ + + + rules2 + + .*\.dSYM($|/) + + weight + 11 + + ^(.*/)?\.DS_Store$ + + omit + + weight + 2000 + + ^.* + + ^.*\.lproj/ + + optional + + weight + 1000 + + ^.*\.lproj/locversion.plist$ + + omit + + weight + 1100 + + ^Base\.lproj/ + + weight + 1010 + + ^Info\.plist$ + + omit + + weight + 20 + + ^PkgInfo$ + + omit + + weight + 20 + + ^embedded\.provisionprofile$ + + weight + 20 + + ^version\.plist$ + + weight + 20 + + + + diff --git a/Frameworks/cactus-ios.xcframework/ios-arm64-simulator/cactus.framework/cactus b/Frameworks/cactus-ios.xcframework/ios-arm64-simulator/cactus.framework/cactus new file mode 100755 index 00000000..1dbb2944 Binary files /dev/null and b/Frameworks/cactus-ios.xcframework/ios-arm64-simulator/cactus.framework/cactus differ diff --git a/Frameworks/cactus-ios.xcframework/ios-arm64/cactus.framework/Headers/cactus.h b/Frameworks/cactus-ios.xcframework/ios-arm64/cactus.framework/Headers/cactus.h new file mode 100644 index 00000000..b5b6d803 --- /dev/null +++ b/Frameworks/cactus-ios.xcframework/ios-arm64/cactus.framework/Headers/cactus.h @@ -0,0 +1,12 @@ +#ifndef CACTUS_H +#define CACTUS_H + +#include "graph/graph.h" +#include "kernel/kernel.h" +#include "kernel/kernel_utils.h" +#include "engine/engine.h" +#include "models/model.h" +#include "ffi/cactus_ffi.h" +#include "npu/npu.h" + +#endif // CACTUS_H \ No newline at end of file diff --git a/Frameworks/cactus-ios.xcframework/ios-arm64/cactus.framework/Headers/cactus_cloud.h b/Frameworks/cactus-ios.xcframework/ios-arm64/cactus.framework/Headers/cactus_cloud.h new file mode 100644 index 00000000..e61841d7 --- /dev/null +++ b/Frameworks/cactus-ios.xcframework/ios-arm64/cactus.framework/Headers/cactus_cloud.h @@ -0,0 +1,48 @@ +#ifndef CACTUS_CLOUD_H +#define CACTUS_CLOUD_H + +#include "cactus_utils.h" +#include +#include + +namespace cactus { +namespace ffi { + +struct CloudResponse { + std::string transcript; + std::string api_key_hash; + bool used_cloud = false; + std::string error; +}; + +struct CloudCompletionRequest { + std::vector messages; + std::vector tools; + std::string local_output; + std::vector local_function_calls; + bool has_images = false; + std::string cloud_key; +}; + +struct CloudCompletionResult { + bool ok = false; + bool used_cloud = false; + std::string response; + std::vector function_calls; + std::string error; +}; + +std::string cloud_base64_encode(const uint8_t* data, size_t len); +std::vector cloud_build_wav(const uint8_t* pcm, size_t pcm_bytes); +std::string resolve_cloud_api_key(const char* cloud_key_param); +CloudResponse cloud_transcribe_request(const std::string& audio_b64, + const std::string& fallback_text, + long timeout_seconds = 15L, + const char* cloud_key = nullptr); +CloudCompletionResult cloud_complete_request(const CloudCompletionRequest& request, + long timeout_ms); + +} // namespace ffi +} // namespace cactus + +#endif // CACTUS_CLOUD_H diff --git a/Frameworks/cactus-ios.xcframework/ios-arm64/cactus.framework/Headers/cactus_ffi.h b/Frameworks/cactus-ios.xcframework/ios-arm64/cactus.framework/Headers/cactus_ffi.h new file mode 100644 index 00000000..163e1805 --- /dev/null +++ b/Frameworks/cactus-ios.xcframework/ios-arm64/cactus.framework/Headers/cactus_ffi.h @@ -0,0 +1,454 @@ +#ifndef CACTUS_FFI_H +#define CACTUS_FFI_H + +#include +#include +#include + +#if __GNUC__ >= 4 + #define CACTUS_FFI_EXPORT __attribute__((visibility("default"))) + #define CACTUS_FFI_LOCAL __attribute__((visibility("hidden"))) +#else + #define CACTUS_FFI_EXPORT + #define CACTUS_FFI_LOCAL +#endif + +#ifdef __cplusplus +extern "C" { +#endif + +typedef void* cactus_model_t; +typedef void* cactus_index_t; +typedef void* cactus_stream_transcribe_t; + +typedef void (*cactus_token_callback)(const char* token, uint32_t token_id, void* user_data); + +CACTUS_FFI_EXPORT cactus_model_t cactus_init( + const char* model_path, + const char* corpus_dir, // optional: NULL if no RAG corpus + bool cache_index // false = always rebuild index, true = load cached if available +); + +CACTUS_FFI_EXPORT void cactus_destroy(cactus_model_t model); +CACTUS_FFI_EXPORT void cactus_reset(cactus_model_t model); +CACTUS_FFI_EXPORT void cactus_stop(cactus_model_t model); + +CACTUS_FFI_EXPORT int cactus_complete( + cactus_model_t model, + const char* messages_json, + char* response_buffer, + size_t buffer_size, + const char* options_json, // optional + const char* tools_json, // optional + cactus_token_callback callback, // optional + void* user_data, // optional + const uint8_t* pcm_buffer, // optional: NULL when not used + size_t pcm_buffer_size // optional: 0 when not used +); + +CACTUS_FFI_EXPORT int cactus_prefill( + cactus_model_t model, + const char* messages_json, + char* response_buffer, + size_t buffer_size, + const char* options_json, // optional + const char* tools_json, // optional + const uint8_t* pcm_buffer, // optional: NULL when not used + size_t pcm_buffer_size // optional: 0 when not used +); + +CACTUS_FFI_EXPORT int cactus_tokenize( + cactus_model_t model, + const char* text, + uint32_t* token_buffer, + size_t token_buffer_len, + size_t* out_token_len +); + +CACTUS_FFI_EXPORT int cactus_score_window( + cactus_model_t model, + const uint32_t* tokens, + size_t token_len, + size_t start, + size_t end, + size_t context, + char* response_buffer, + size_t buffer_size +); + +CACTUS_FFI_EXPORT int cactus_transcribe( + cactus_model_t model, + const char* audio_file_path, // NULL if using pcm_buffer + const char* prompt, + char* response_buffer, + size_t buffer_size, + const char* options_json, // optional + cactus_token_callback callback, // optional + void* user_data, // optional + const uint8_t* pcm_buffer, // NULL if using audio_file_path + size_t pcm_buffer_size +); + +CACTUS_FFI_EXPORT int cactus_detect_language( + cactus_model_t model, + const char* audio_file_path, // NULL if using pcm_buffer + char* response_buffer, + size_t buffer_size, + const char* options_json, // optional + const uint8_t* pcm_buffer, // NULL if using audio_file_path + size_t pcm_buffer_size +); + +CACTUS_FFI_EXPORT cactus_stream_transcribe_t cactus_stream_transcribe_start( + cactus_model_t model, + const char* options_json // optional +); + +CACTUS_FFI_EXPORT int cactus_stream_transcribe_process( + cactus_stream_transcribe_t stream, + const uint8_t* pcm_buffer, + size_t pcm_buffer_size, + char* response_buffer, + size_t buffer_size +); + +CACTUS_FFI_EXPORT int cactus_stream_transcribe_stop( + cactus_stream_transcribe_t stream, + char* response_buffer, + size_t buffer_size +); + +CACTUS_FFI_EXPORT int cactus_embed( + cactus_model_t model, + const char* text, + float* embeddings_buffer, + size_t buffer_size, + size_t* embedding_dim, + bool normalize +); + +CACTUS_FFI_EXPORT int cactus_image_embed( + cactus_model_t model, + const char* image_path, + float* embeddings_buffer, + size_t buffer_size, + size_t* embedding_dim +); + +CACTUS_FFI_EXPORT int cactus_audio_embed( + cactus_model_t model, + const char* audio_path, + float* embeddings_buffer, + size_t buffer_size, + size_t* embedding_dim +); + +CACTUS_FFI_EXPORT int cactus_vad( + cactus_model_t model, + const char* audio_file_path, + char* response_buffer, + size_t buffer_size, + const char* options_json, + const uint8_t* pcm_buffer, + size_t pcm_buffer_size +); + +CACTUS_FFI_EXPORT int cactus_diarize( + cactus_model_t model, + const char* audio_file_path, + char* response_buffer, + size_t buffer_size, + const char* options_json, + const uint8_t* pcm_buffer, + size_t pcm_buffer_size +); + +CACTUS_FFI_EXPORT int cactus_embed_speaker( + cactus_model_t model, + const char* audio_file_path, + char* response_buffer, + size_t buffer_size, + const char* options_json, + const uint8_t* pcm_buffer, + size_t pcm_buffer_size, + const float* mask_weights, + size_t mask_num_frames +); + +CACTUS_FFI_EXPORT int cactus_rag_query( + cactus_model_t model, + const char* query, + char* response_buffer, + size_t buffer_size, + size_t top_k +); + +CACTUS_FFI_EXPORT cactus_index_t cactus_index_init( + const char* index_dir, + size_t embedding_dim +); + +CACTUS_FFI_EXPORT int cactus_index_add( + cactus_index_t index, + const int* ids, + const char** documents, + const char** metadatas, // optional: can be NULL + const float** embeddings, + size_t count, + size_t embedding_dim +); + +CACTUS_FFI_EXPORT int cactus_index_delete( + cactus_index_t index, + const int* ids, + size_t ids_count +); + +CACTUS_FFI_EXPORT int cactus_index_get( + cactus_index_t index, + const int* ids, + size_t ids_count, + char** document_buffers, + size_t* document_buffer_sizes, + char** metadata_buffers, + size_t* metadata_buffer_sizes, + float** embedding_buffers, + size_t* embedding_buffer_sizes +); + +CACTUS_FFI_EXPORT int cactus_index_query( + cactus_index_t index, + const float** embeddings, + size_t embeddings_count, + size_t embedding_dim, + const char* options_json, // optional + int** id_buffers, + size_t* id_buffer_sizes, + float** score_buffers, + size_t* score_buffer_sizes +); + +CACTUS_FFI_EXPORT int cactus_index_compact(cactus_index_t index); +CACTUS_FFI_EXPORT void cactus_index_destroy(cactus_index_t index); + +CACTUS_FFI_EXPORT const char* cactus_get_last_error(void); + +// level: 0=DEBUG, 1=INFO, 2=WARN (default), 3=ERROR, 4=NONE +CACTUS_FFI_EXPORT void cactus_log_set_level(int level); + +typedef void (*cactus_log_callback_t)(int level, const char* component, const char* message, void* user_data); +CACTUS_FFI_EXPORT void cactus_log_set_callback(cactus_log_callback_t callback, void* user_data); + +CACTUS_FFI_EXPORT void cactus_set_telemetry_environment(const char* framework, const char* cache_location, const char* version); +CACTUS_FFI_EXPORT void cactus_set_app_id(const char* app_id); +CACTUS_FFI_EXPORT void cactus_telemetry_flush(void); +CACTUS_FFI_EXPORT void cactus_telemetry_shutdown(void); + +// cactus graph export +typedef void* cactus_graph_t; +typedef uint64_t cactus_node_t; + +typedef struct { + int32_t precision; + size_t rank; + size_t shape[8]; + size_t num_elements; + size_t byte_size; +} cactus_tensor_info_t; + +CACTUS_FFI_EXPORT cactus_graph_t cactus_graph_create(void); +CACTUS_FFI_EXPORT void cactus_graph_destroy(cactus_graph_t graph); +CACTUS_FFI_EXPORT int cactus_graph_hard_reset(cactus_graph_t graph); + +CACTUS_FFI_EXPORT int cactus_graph_save(cactus_graph_t graph, const char* filename); +CACTUS_FFI_EXPORT cactus_graph_t cactus_graph_load(const char* filename); + +CACTUS_FFI_EXPORT int cactus_graph_input( + cactus_graph_t graph, const size_t* shape, size_t rank, int32_t precision, +cactus_node_t* out_node); + +CACTUS_FFI_EXPORT int cactus_graph_set_input( + cactus_graph_t graph, cactus_node_t node, const void* data, int32_t +precision); +CACTUS_FFI_EXPORT int cactus_graph_set_external_input( + cactus_graph_t graph, cactus_node_t node, void* data, int32_t precision); + +CACTUS_FFI_EXPORT int cactus_graph_precision_cast( + cactus_graph_t graph, cactus_node_t input, int32_t target_precision, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_quantize_activations( + cactus_graph_t graph, cactus_node_t input, cactus_node_t* out); + +CACTUS_FFI_EXPORT int cactus_graph_add(cactus_graph_t graph, cactus_node_t a, +cactus_node_t b, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_add_clipped(cactus_graph_t graph, cactus_node_t a, +cactus_node_t b, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_subtract(cactus_graph_t graph, cactus_node_t +a, cactus_node_t b, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_multiply(cactus_graph_t graph, cactus_node_t +a, cactus_node_t b, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_divide(cactus_graph_t graph, cactus_node_t +a, cactus_node_t b, cactus_node_t* out); + +CACTUS_FFI_EXPORT int cactus_graph_scalar_add(cactus_graph_t graph, cactus_node_t x, float value, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_scalar_subtract(cactus_graph_t graph, cactus_node_t x, float value, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_scalar_multiply(cactus_graph_t graph, cactus_node_t x, float value, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_scalar_divide(cactus_graph_t graph, cactus_node_t x, float value, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_scalar_exp(cactus_graph_t graph, cactus_node_t x, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_scalar_sqrt(cactus_graph_t graph, cactus_node_t x, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_scalar_cos(cactus_graph_t graph, cactus_node_t x, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_scalar_sin(cactus_graph_t graph, cactus_node_t x, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_scalar_log(cactus_graph_t graph, cactus_node_t x, cactus_node_t* out); + +CACTUS_FFI_EXPORT int cactus_graph_abs(cactus_graph_t graph, cactus_node_t x, +cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_pow(cactus_graph_t graph, cactus_node_t x, +float exponent, cactus_node_t* out); + +CACTUS_FFI_EXPORT int cactus_graph_view( + cactus_graph_t graph, cactus_node_t x, const size_t* shape, size_t rank, +cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_flatten( + cactus_graph_t graph, cactus_node_t x, int32_t start_dim, int32_t end_dim, +cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_reshape( + cactus_graph_t graph, cactus_node_t x, const size_t* shape, size_t rank, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_transpose( + cactus_graph_t graph, cactus_node_t x, int32_t backend, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_transpose_n( + cactus_graph_t graph, cactus_node_t x, const size_t* permutation, size_t rank, int32_t backend, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_slice( + cactus_graph_t graph, cactus_node_t x, int32_t axis, size_t start, size_t length, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_index( + cactus_graph_t graph, cactus_node_t x, size_t index_value, int32_t dim, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_sum(cactus_graph_t graph, cactus_node_t x, int32_t axis, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_mean(cactus_graph_t graph, cactus_node_t x, int32_t axis, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_variance(cactus_graph_t graph, cactus_node_t x, int32_t axis, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_min(cactus_graph_t graph, cactus_node_t x, int32_t axis, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_max(cactus_graph_t graph, cactus_node_t x, int32_t axis, cactus_node_t* out); + +CACTUS_FFI_EXPORT int cactus_graph_concat( + cactus_graph_t graph, cactus_node_t a, cactus_node_t b, int32_t axis, +cactus_node_t* out); + +CACTUS_FFI_EXPORT int cactus_graph_cat( + cactus_graph_t graph, const cactus_node_t* nodes, size_t count, int32_t +axis, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_matmul( + cactus_graph_t graph, cactus_node_t a, cactus_node_t b, bool pretransposed_rhs, int32_t backend, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_gather( + cactus_graph_t graph, cactus_node_t tensor, cactus_node_t indices, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_embedding_from_tensor( + cactus_graph_t graph, cactus_node_t embedding_tensor, cactus_node_t indices, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_embedding_from_file( + cactus_graph_t graph, const char* filename, cactus_node_t indices, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_mmap_embeddings( + cactus_graph_t graph, const char* filename, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_mmap_weights( + cactus_graph_t graph, const char* filename, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_bilinear_interpolation( + cactus_graph_t graph, cactus_node_t pos_embeds, size_t dst_height, size_t dst_width, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_set_grouped_scales( + cactus_graph_t graph, cactus_node_t node, size_t group_size, size_t num_groups, void* scales_ptr); +CACTUS_FFI_EXPORT int cactus_graph_set_interleaved( + cactus_graph_t graph, cactus_node_t node, bool interleaved, size_t original_n); +CACTUS_FFI_EXPORT int cactus_graph_release_weight_pages(cactus_graph_t graph, cactus_node_t node); +CACTUS_FFI_EXPORT int cactus_graph_prefetch_weight_pages(cactus_graph_t graph, cactus_node_t node); +CACTUS_FFI_EXPORT int cactus_graph_release_all_weight_pages(cactus_graph_t graph); + +CACTUS_FFI_EXPORT int cactus_graph_relu(cactus_graph_t graph, cactus_node_t x, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_silu(cactus_graph_t graph, cactus_node_t x, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_gelu(cactus_graph_t graph, cactus_node_t x, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_gelu_erf(cactus_graph_t graph, cactus_node_t x, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_sigmoid(cactus_graph_t graph, cactus_node_t x, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_tanh(cactus_graph_t graph, cactus_node_t x, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_glu(cactus_graph_t graph, cactus_node_t x, int32_t axis, cactus_node_t* out); + +CACTUS_FFI_EXPORT int cactus_graph_layernorm( + cactus_graph_t graph, cactus_node_t input, cactus_node_t weight, cactus_node_t bias, float epsilon, bool has_bias, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_groupnorm( + cactus_graph_t graph, cactus_node_t input, cactus_node_t weight, cactus_node_t bias, size_t num_groups, float epsilon, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_batchnorm( + cactus_graph_t graph, cactus_node_t input, cactus_node_t weight, cactus_node_t bias, cactus_node_t running_mean, cactus_node_t running_var, int32_t axis, float epsilon, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_topk(cactus_graph_t graph, cactus_node_t input, size_t k, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_rms_norm( + cactus_graph_t graph, cactus_node_t input, cactus_node_t weight, float epsilon, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_rope( + cactus_graph_t graph, cactus_node_t input, float theta, size_t position_offset, int32_t backend, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_rope_gptj( + cactus_graph_t graph, cactus_node_t input, float theta, size_t position_offset, size_t rot_dim, int32_t backend, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_softmax(cactus_graph_t graph, cactus_node_t input, int32_t axis, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_attention( + cactus_graph_t graph, cactus_node_t query, cactus_node_t key, cactus_node_t value, float scale, bool is_causal, size_t position_offset, size_t window_size, int32_t backend, bool use_mask, cactus_node_t mask, bool additive_mask, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_rel_pos_bias( + cactus_graph_t graph, cactus_node_t query, cactus_node_t relative_key, float scale, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_attention_int8_hybrid( + cactus_graph_t graph, cactus_node_t query, cactus_node_t key_new, cactus_node_t value_new, float scale, size_t position_offset, + const int8_t* cached_keys, const int8_t* cached_values, const float* k_scales, const float* v_scales, + size_t cache_len, size_t num_kv_heads, size_t head_dim, size_t window_size, cactus_node_t* out); + +CACTUS_FFI_EXPORT int cactus_graph_conv1d_causal( + cactus_graph_t graph, cactus_node_t input, cactus_node_t weight, size_t kernel_size, size_t dilation, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_conv1d_k3( + cactus_graph_t graph, cactus_node_t input, cactus_node_t weight, size_t stride, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_conv1d_k7s3( + cactus_graph_t graph, cactus_node_t input, cactus_node_t weight, cactus_node_t bias, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_conv1d( + cactus_graph_t graph, cactus_node_t input, cactus_node_t weight, bool has_bias, cactus_node_t bias, size_t stride, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_conv1d_same_depthwise_k9( + cactus_graph_t graph, cactus_node_t input, cactus_node_t weight, bool has_bias, cactus_node_t bias, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_conv1d_pointwise( + cactus_graph_t graph, cactus_node_t input, cactus_node_t weight, bool has_bias, cactus_node_t bias, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_conv2d_k3s2p1( + cactus_graph_t graph, cactus_node_t input, cactus_node_t weight, bool has_bias, cactus_node_t bias, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_conv2d_depthwise_k3s2p1( + cactus_graph_t graph, cactus_node_t input, cactus_node_t weight, bool has_bias, cactus_node_t bias, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_conv2d_pointwise_1x1( + cactus_graph_t graph, cactus_node_t input, cactus_node_t weight, bool has_bias, cactus_node_t bias, cactus_node_t* out); + +CACTUS_FFI_EXPORT int cactus_graph_lstm_cell( + cactus_graph_t graph, cactus_node_t input, cactus_node_t h_prev, cactus_node_t c_prev, cactus_node_t weight_ih, cactus_node_t weight_hh, cactus_node_t bias_ih, cactus_node_t bias_hh, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_gated_deltanet_decode( + cactus_graph_t graph, cactus_node_t query, cactus_node_t key, cactus_node_t value, cactus_node_t gate_log, cactus_node_t beta, cactus_node_t initial_state, float scale, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_gated_deltanet_prefill( + cactus_graph_t graph, cactus_node_t query, cactus_node_t key, cactus_node_t value, cactus_node_t gate_log, cactus_node_t beta, cactus_node_t initial_state, size_t chunk_size, float scale, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_stft( + cactus_graph_t graph, cactus_node_t input, cactus_node_t weight, size_t stride, size_t num_fft_bins, cactus_node_t* out); + +CACTUS_FFI_EXPORT int cactus_graph_altup_predict( + cactus_graph_t graph, cactus_node_t coefs, const cactus_node_t* streams, size_t num_streams, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_altup_correct( + cactus_graph_t graph, cactus_node_t coefs, cactus_node_t innovation, const cactus_node_t* predictions, size_t num_predictions, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_gaussian_topk( + cactus_graph_t graph, cactus_node_t input, float ppf, cactus_node_t* out); + +CACTUS_FFI_EXPORT int cactus_graph_moe_layer_gated( + cactus_graph_t graph, cactus_node_t hidden, cactus_node_t routing_probs, cactus_node_t topk_indices, + const cactus_node_t* w1_weights, const cactus_node_t* w3_weights, const cactus_node_t* w2_weights, + size_t num_experts, size_t num_experts_per_tok, bool normalize_routing, float epsilon, float routed_scaling_factor, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_moe_layer_ungated( + cactus_graph_t graph, cactus_node_t hidden, cactus_node_t routing_probs, cactus_node_t topk_indices, + const cactus_node_t* w1_weights, const cactus_node_t* w2_weights, + size_t num_experts, size_t num_experts_per_tok, bool normalize_routing, float epsilon, float routed_scaling_factor, int32_t activation, cactus_node_t* out); + +CACTUS_FFI_EXPORT int cactus_graph_sample( + cactus_graph_t graph, cactus_node_t logits, float temperature, float top_p, size_t top_k, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_scatter_topk( + cactus_graph_t graph, cactus_node_t indices, cactus_node_t values, size_t num_classes, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_persistent( + cactus_graph_t graph, cactus_node_t source_node, cactus_node_t* out); +CACTUS_FFI_EXPORT int cactus_graph_is_populated( + cactus_graph_t graph, cactus_node_t persistent_node, int32_t* out_is_populated); +CACTUS_FFI_EXPORT int cactus_graph_invalidate_persistent( + cactus_graph_t graph, cactus_node_t persistent_node); + +CACTUS_FFI_EXPORT int cactus_graph_execute(cactus_graph_t graph); +CACTUS_FFI_EXPORT int cactus_graph_get_output_ptr(cactus_graph_t graph, +cactus_node_t node, void** out_ptr); +CACTUS_FFI_EXPORT int cactus_graph_get_output_info(cactus_graph_t graph, +cactus_node_t node, cactus_tensor_info_t* out_info); + +#ifdef __cplusplus +} +#endif + +#endif // CACTUS_FFI_H diff --git a/Frameworks/cactus-ios.xcframework/ios-arm64/cactus.framework/Headers/cactus_utils.h b/Frameworks/cactus-ios.xcframework/ios-arm64/cactus.framework/Headers/cactus_utils.h new file mode 100644 index 00000000..bc2d4d58 --- /dev/null +++ b/Frameworks/cactus-ios.xcframework/ios-arm64/cactus.framework/Headers/cactus_utils.h @@ -0,0 +1,1850 @@ +#ifndef CACTUS_UTILS_H +#define CACTUS_UTILS_H + +#include "../engine/engine.h" +#include "../models/model.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef __APPLE__ +#include +#include +#elif defined(_WIN32) +#include +#include +#elif defined(__linux__) || defined(__ANDROID__) +#include +#endif + +inline size_t get_memory_footprint_bytes() { +#ifdef __APPLE__ + task_vm_info_data_t vm_info; + mach_msg_type_number_t count = TASK_VM_INFO_COUNT; + if (task_info(mach_task_self(), TASK_VM_INFO, (task_info_t)&vm_info, &count) == KERN_SUCCESS) + return vm_info.phys_footprint; + +#elif defined(_WIN32) + PROCESS_MEMORY_COUNTERS_EX pmc; + if (GetProcessMemoryInfo(GetCurrentProcess(), (PROCESS_MEMORY_COUNTERS*)&pmc, sizeof(pmc))) + return pmc.PrivateUsage; + +#elif defined(__linux__) || defined(__ANDROID__) + std::ifstream statm("/proc/self/statm"); + if (statm.is_open()) { + size_t size, resident; + statm >> size >> resident; + return resident * sysconf(_SC_PAGESIZE); + } +#endif + return 0; +} + +inline double get_ram_usage_mb() { + return get_memory_footprint_bytes() / (1024.0 * 1024.0); +} + +struct CactusModelHandle { + std::unique_ptr model; + std::unique_ptr vad_model; + std::atomic should_stop; + std::vector processed_tokens; + struct ProcessedImage { + std::string path; + long long last_modified_timestamp = 0; + + bool operator==(const ProcessedImage& other) const { + return path == other.path && last_modified_timestamp == other.last_modified_timestamp; + } + }; + + std::vector> processed_images; + std::mutex model_mutex; + std::string model_name; + std::unique_ptr corpus_index; + std::string corpus_dir; + size_t corpus_embedding_dim = 0; + std::vector> tool_embeddings; + std::vector tool_texts; + + CactusModelHandle() : should_stop(false) {} +}; + +extern std::string last_error_message; + +bool matches_stop_sequence(const std::vector& generated_tokens, + const std::vector>& stop_sequences); + +std::string retrieve_rag_context(CactusModelHandle* handle, const std::string& query); + +namespace cactus { +namespace audio { + +static constexpr size_t WHISPER_TARGET_FRAMES = 3000; +static constexpr int WHISPER_SAMPLE_RATE = 16000; + +inline cactus::engine::AudioProcessor::SpectrogramConfig get_whisper_spectrogram_config() { + cactus::engine::AudioProcessor::SpectrogramConfig cfg{}; + cfg.n_fft = 400; + cfg.frame_length = 400; + cfg.hop_length = 160; + cfg.power = 2.0f; + cfg.center = true; + cfg.pad_mode = "reflect"; + cfg.onesided = true; + cfg.dither = 0.0f; + cfg.mel_floor = 1e-10f; + cfg.log_mel = "log10"; + cfg.reference = 1.0f; + cfg.min_value = 1e-10f; + cfg.remove_dc_offset = true; + return cfg; +} + +inline cactus::engine::AudioProcessor::SpectrogramConfig get_parakeet_spectrogram_config() { + cactus::engine::AudioProcessor::SpectrogramConfig cfg{}; + cfg.n_fft = 512; + cfg.frame_length = 400; + cfg.hop_length = 160; + cfg.power = 2.0f; + cfg.center = true; + cfg.pad_mode = "constant"; + cfg.onesided = true; + cfg.dither = 0.0f; + cfg.mel_floor = 5.960464477539063e-08f; // 2^-24 guard value used by HF Parakeet. + cfg.log_mel = "log"; + cfg.reference = 1.0f; + cfg.min_value = 1e-10f; + cfg.remove_dc_offset = false; + cfg.hann_periodic = false; + return cfg; +} + +inline cactus::engine::AudioProcessor::SpectrogramConfig get_htk_spectrogram_config() { + cactus::engine::AudioProcessor::SpectrogramConfig cfg{}; + cfg.n_fft = 321; + cfg.frame_length = 320; + cfg.fft_override = 1024; + cfg.hop_length = 160; + cfg.power = 1.0f; + cfg.center = false; + cfg.pad_mode = "constant"; + cfg.onesided = true; + cfg.dither = 0.0f; + cfg.mel_floor = 0.001f; + cfg.log_mel = "log"; + cfg.reference = 1.0f; + cfg.min_value = 0.001f; + cfg.remove_dc_offset = false; + cfg.hann_periodic = true; + return cfg; +} + +inline cactus::engine::AudioProcessor::SpectrogramConfig get_gemma4_audio_spectrogram_config( + const cactus::engine::Config& model_config) { + auto cfg = get_htk_spectrogram_config(); + cfg.fft_override = model_config.audio_fft_length; + cfg.mel_floor_additive = true; + return cfg; +} + +inline cactus::engine::AudioProcessor::SpectrogramConfig get_wespeaker_spectrogram_config() { + cactus::engine::AudioProcessor::SpectrogramConfig cfg{}; + cfg.n_fft = 512; + cfg.frame_length = 400; + cfg.hop_length = 160; + cfg.power = 2.0f; + cfg.center = false; + cfg.pad_mode = "constant"; + cfg.onesided = true; + cfg.dither = 0.0f; + cfg.mel_floor = 1.1754944e-38f; + cfg.log_mel = "log"; + cfg.reference = 1.0f; + cfg.min_value = 1.1754944e-38f; + cfg.remove_dc_offset = true; + cfg.preemphasis = 0.97f; + cfg.hann_periodic = false; + cfg.window_a0 = 0.54f; + return cfg; +} + +// Whisper v1/v2: 80 mel bins, HTK. Whisper v3: 128 mel bins, Slaney, 512-FFT, no DC removal. +inline void init_whisper_mel_filters(cactus::engine::AudioProcessor& ap, + cactus::engine::AudioProcessor::SpectrogramConfig& cfg, + size_t mel_bins) { + const size_t num_mel_filters = std::max(1, mel_bins); + const bool is_v3 = mel_bins > 80; + if (is_v3) { + cfg.fft_override = 512; + cfg.remove_dc_offset = false; + } + const size_t fft_len = cfg.fft_override > 0 ? cfg.fft_override : cfg.n_fft; + const size_t num_frequency_bins = fft_len / 2 + 1; + if (is_v3) { + ap.init_mel_filters(num_frequency_bins, num_mel_filters, 0.0f, 8000.0f, + WHISPER_SAMPLE_RATE, "slaney", "slaney"); + } else { + ap.init_mel_filters(num_frequency_bins, num_mel_filters, 0.0f, 8000.0f, + WHISPER_SAMPLE_RATE); + } +} + +// use_mel_floor_padding=true pads short audio with the normalized mel floor (required for v3). +inline std::vector normalize_whisper_mel(std::vector& mel, size_t n_mels, + bool use_mel_floor_padding = false) { + if (mel.empty() || n_mels == 0) return mel; + size_t n_frames = mel.size() / n_mels; + + float max_val = -std::numeric_limits::infinity(); + for (float v : mel) if (v > max_val) max_val = v; + + float min_allowed = max_val - 8.0f; + for (float& v : mel) { + if (v < min_allowed) v = min_allowed; + v = (v + 4.0f) * 0.25f; + } + + if (n_frames != WHISPER_TARGET_FRAMES) { + float pad_val = use_mel_floor_padding ? (min_allowed + 4.0f) * 0.25f : 0.0f; + std::vector fixed(n_mels * WHISPER_TARGET_FRAMES, pad_val); + size_t copy_frames = std::min(n_frames, WHISPER_TARGET_FRAMES); + for (size_t m = 0; m < n_mels; ++m) { + const float* src = &mel[m * n_frames]; + float* dst = &fixed[m * WHISPER_TARGET_FRAMES]; + std::copy(src, src + copy_frames, dst); + } + return fixed; + } + return std::move(mel); +} + +inline std::vector transpose_mel_to_frame_major(const std::vector& mel, + size_t num_mels, size_t num_frames) { + std::vector transposed(num_frames * num_mels); + for (size_t m = 0; m < num_mels; m++) { + for (size_t t = 0; t < num_frames; t++) { + transposed[t * num_mels + m] = mel[m * num_frames + t]; + } + } + return transposed; +} + +inline void apply_preemphasis(std::vector& waveform, float coefficient = 0.97f) { + if (waveform.size() < 2 || coefficient == 0.0f) { + return; + } + for (size_t i = waveform.size() - 1; i > 0; --i) { + waveform[i] -= coefficient * waveform[i - 1]; + } +} + +inline void normalize_parakeet_log_mel(std::vector& mel, size_t num_mels, float epsilon = 1e-5f) { + if (mel.empty() || num_mels == 0 || (mel.size() % num_mels) != 0) { + return; + } + const size_t num_frames = mel.size() / num_mels; + if (num_frames == 0) { + return; + } + + for (size_t m = 0; m < num_mels; ++m) { + const size_t base = m * num_frames; + float mean = 0.0f; + for (size_t t = 0; t < num_frames; ++t) { + mean += mel[base + t]; + } + mean /= static_cast(num_frames); + + float variance = 0.0f; + for (size_t t = 0; t < num_frames; ++t) { + const float d = mel[base + t] - mean; + variance += d * d; + } + const float denom = static_cast(std::max(1, num_frames - 1)); + const float inv_std = 1.0f / std::sqrt((variance / denom) + epsilon); + for (size_t t = 0; t < num_frames; ++t) { + mel[base + t] = (mel[base + t] - mean) * inv_std; + } + } +} + +inline void trim_mel_frames(std::vector& mel, size_t num_mels, size_t valid_frames) { + if (mel.empty() || num_mels == 0 || (mel.size() % num_mels) != 0) { + return; + } + size_t total_frames = mel.size() / num_mels; + if (valid_frames == 0 || valid_frames >= total_frames) { + return; + } + std::vector trimmed(num_mels * valid_frames); + for (size_t m = 0; m < num_mels; ++m) { + const float* src = &mel[m * total_frames]; + float* dst = &trimmed[m * valid_frames]; + std::copy(src, src + valid_frames, dst); + } + mel.swap(trimmed); +} + +struct AudioPreprocessResult { + std::vector features; + size_t num_frames = 0; + size_t num_soft_tokens = 0; +}; + +inline AudioPreprocessResult preprocess_audio_for_gemma4( + std::vector audio_samples, + const cactus::engine::Config& model_config +) { + AudioPreprocessResult result; + if (audio_samples.empty()) return result; + + size_t pad_amt = 320 - (audio_samples.size() % 320); + if (pad_amt < 320) + audio_samples.resize(audio_samples.size() + pad_amt, 0.0f); + + size_t mel_bins = model_config.audio_input_feat_size; + auto cfg = get_gemma4_audio_spectrogram_config(model_config); + + size_t semicausal_pad = cfg.frame_length / 2; + audio_samples.insert(audio_samples.begin(), semicausal_pad, 0.0f); + + cactus::engine::AudioProcessor ap; + size_t fft_for_mel = cfg.fft_override > 0 ? cfg.fft_override : cfg.n_fft; + ap.init_mel_filters(fft_for_mel / 2 + 1, mel_bins, 0.0f, 8000.0f, 16000, + nullptr, "htk"); + std::vector mel = ap.compute_spectrogram(audio_samples, cfg); + + result.num_frames = mel.size() / mel_bins; + result.features = transpose_mel_to_frame_major(mel, mel_bins, result.num_frames); + + size_t after_stage1 = (result.num_frames + 1) / 2; + result.num_soft_tokens = (after_stage1 + 1) / 2; + + return result; +} + +inline std::vector pcm_buffer_to_float_samples( + const uint8_t* pcm_buffer, size_t pcm_buffer_size +) { + const int16_t* pcm_samples = reinterpret_cast(pcm_buffer); + size_t num_samples = pcm_buffer_size / 2; + std::vector waveform_fp32(num_samples); + constexpr float inv_32768 = 1.0f / 32768.0f; + for (size_t i = 0; i < num_samples; i++) + waveform_fp32[i] = static_cast(pcm_samples[i]) * inv_32768; + return waveform_fp32; +} + +} // namespace audio +} // namespace cactus + +namespace cactus { +namespace ffi { + +inline bool env_flag_enabled(const char* key) { + const char* value = std::getenv(key); + return value && value[0] != '\0' && !(value[0] == '0' && value[1] == '\0'); +} + +inline std::string generateUUID() { +#ifdef __APPLE__ + uuid_t uuid; + uuid_generate_random(uuid); + char uuid_str[37]; + uuid_unparse_lower(uuid, uuid_str); + return std::string(uuid_str); +#else + static std::random_device rd; + static std::mt19937 gen(rd()); + static std::uniform_int_distribution<> dis(0, 15); + static std::uniform_int_distribution<> dis2(8, 11); + + std::stringstream ss; + ss << std::hex; + for (int i = 0; i < 8; i++) ss << dis(gen); + ss << "-"; + for (int i = 0; i < 4; i++) ss << dis(gen); + ss << "-4"; + for (int i = 0; i < 3; i++) ss << dis(gen); + ss << "-"; + ss << dis2(gen); + for (int i = 0; i < 3; i++) ss << dis(gen); + ss << "-"; + for (int i = 0; i < 12; i++) ss << dis(gen); + return ss.str(); +#endif +} + +struct ToolFunction { + std::string name; + std::string description; + std::unordered_map parameters; +}; + +struct InferenceOptions { + float temperature = 0.0f; + float top_p = 0.0f; + float min_p = 0.15f; + float repetition_penalty = 1.1f; + float confidence_threshold = 0.7f; + size_t top_k = 0; + size_t max_tokens = 100; + size_t tool_rag_top_k = 2; + size_t cloud_timeout_ms = 15000; + std::vector stop_sequences; + bool force_tools = false; + bool include_stop_sequences = false; + bool use_vad = true; + bool telemetry_enabled = true; + bool auto_handoff = true; + bool handoff_with_images = true; + bool enable_thinking_if_supported = false; +}; + +} // namespace ffi +} // namespace cactus + +std::vector select_relevant_tools( + CactusModelHandle* handle, + const std::string& query, + const std::vector& all_tools, + size_t top_k); + +#include "gemma_tools.h" + +namespace cactus { +namespace ffi { + +inline std::string escape_json_string(const std::string& s) { + std::ostringstream o; + for (char c : s) { + if (c == '"') o << "\\\""; + else if (c == '\n') o << "\\n"; + else if (c == '\r') o << "\\r"; + else if (c == '\t') o << "\\t"; + else if (c == '\\') o << "\\\\"; + else o << c; + } + return o.str(); +} + + +inline std::string trim_string(const std::string& s) { + size_t start = 0; + while (start < s.size() && std::isspace(static_cast(s[start]))) ++start; + size_t end = s.size(); + while (end > start && std::isspace(static_cast(s[end - 1]))) --end; + return s.substr(start, end - start); +} + +inline size_t find_matching_delimiter(const std::string& s, size_t pos, char open, char close) { + int depth = 1; + pos++; + while (pos < s.length() && depth > 0) { + if (s[pos] == open) depth++; + else if (s[pos] == close) depth--; + else if (s[pos] == '"') { + pos++; + while (pos < s.length() && s[pos] != '"') { + if (s[pos] == '\\') pos++; + pos++; + } + } + pos++; + } + return pos; +} + +inline std::string env_or_default(const char* key, const char* fallback) { + const char* v = std::getenv(key); + if (v && v[0] != '\0') return std::string(v); + return std::string(fallback); +} + +inline std::string json_string_field(const std::string& json, const std::string& key) { + std::string pattern = "\"" + key + "\":"; + size_t pos = json.find(pattern); + if (pos == std::string::npos) return {}; + + size_t i = pos + pattern.size(); + while (i < json.size() && std::isspace(static_cast(json[i]))) i++; + if (i >= json.size() || json[i] != '"') return {}; + ++i; + + std::string out; + out.reserve(128); + while (i < json.size()) { + char c = json[i++]; + if (c == '"') return out; + if (c == '\\' && i < json.size()) { + char e = json[i++]; + switch (e) { + case '"': out.push_back('"'); break; + case '\\': out.push_back('\\'); break; + case '/': out.push_back('/'); break; + case 'b': out.push_back('\b'); break; + case 'f': out.push_back('\f'); break; + case 'n': out.push_back('\n'); break; + case 'r': out.push_back('\r'); break; + case 't': out.push_back('\t'); break; + default: out.push_back(e); break; + } + continue; + } + out.push_back(c); + } + return {}; +} + +inline std::string json_array_field(const std::string& json, const std::string& key) { + std::string pattern = "\"" + key + "\":"; + size_t pos = json.find(pattern); + if (pos == std::string::npos) return "[]"; + size_t start = pos + pattern.size(); + while (start < json.size() && std::isspace(static_cast(json[start]))) ++start; + if (start >= json.size() || json[start] != '[') return "[]"; + + int depth = 1; + size_t end = start + 1; + while (end < json.size() && depth > 0) { + if (json[end] == '[') depth++; + else if (json[end] == ']') depth--; + end++; + } + return json.substr(start, end - start); +} + +inline std::vector split_json_array(const std::string& array_json) { + std::vector out; + if (array_json.size() < 2 || array_json.front() != '[' || array_json.back() != ']') return out; + + size_t i = 1; + while (i + 1 < array_json.size()) { + while (i + 1 < array_json.size() && + (std::isspace(static_cast(array_json[i])) || array_json[i] == ',')) i++; + if (i + 1 >= array_json.size() || array_json[i] != '{') break; + + size_t start = i; + int depth = 0; + bool in_str = false; + bool esc = false; + for (; i < array_json.size(); ++i) { + char c = array_json[i]; + if (in_str) { + if (esc) esc = false; + else if (c == '\\') esc = true; + else if (c == '"') in_str = false; + continue; + } + if (c == '"') { in_str = true; continue; } + if (c == '{') depth++; + if (c == '}') { + depth--; + if (depth == 0) { + out.push_back(array_json.substr(start, i - start + 1)); + i++; + break; + } + } + } + } + return out; +} + +inline std::string serialize_tools_json(const std::vector& tools) { + if (tools.empty()) return ""; + std::ostringstream oss; + oss << "["; + for (size_t i = 0; i < tools.size(); ++i) { + if (i > 0) oss << ","; + oss << "{\"type\":\"function\",\"function\":{"; + oss << "\"name\":\"" << escape_json_string(tools[i].name) << "\","; + oss << "\"description\":\"" << escape_json_string(tools[i].description) << "\""; + auto it = tools[i].parameters.find("schema"); + if (it != tools[i].parameters.end()) { + oss << ",\"parameters\":" << it->second; + } + oss << "}}"; + } + oss << "]"; + return oss.str(); +} + +namespace json_sorted { + +inline void skip_ws(const std::string& s, size_t& p) { + while (p < s.size() && std::isspace(static_cast(s[p]))) p++; +} + +inline std::string parse_string(const std::string& s, size_t& p) { + std::string r = "\""; + p++; + while (p < s.size()) { + if (s[p] == '\\') { + r += s[p++]; + if (p < s.size()) r += s[p++]; + } else if (s[p] == '"') { + r += '"'; + p++; + return r; + } else { + r += s[p++]; + } + } + return r; +} + +inline std::string parse_value(const std::string& s, size_t& p); + +inline std::string parse_object(const std::string& s, size_t& p) { + p++; + std::map entries; + skip_ws(s, p); + while (p < s.size() && s[p] != '}') { + if (s[p] == ',') { p++; skip_ws(s, p); continue; } + std::string key = parse_string(s, p); + skip_ws(s, p); + if (p < s.size() && s[p] == ':') p++; + skip_ws(s, p); + std::string val = parse_value(s, p); + entries[key] = val; + skip_ws(s, p); + } + if (p < s.size()) p++; + std::string r = "{"; + bool first = true; + for (const auto& kv : entries) { + if (!first) r += ", "; + r += kv.first + ": " + kv.second; + first = false; + } + r += "}"; + return r; +} + +inline std::string parse_array(const std::string& s, size_t& p) { + p++; + std::vector items; + skip_ws(s, p); + while (p < s.size() && s[p] != ']') { + if (s[p] == ',') { p++; skip_ws(s, p); continue; } + items.push_back(parse_value(s, p)); + skip_ws(s, p); + } + if (p < s.size()) p++; + std::string r = "["; + for (size_t i = 0; i < items.size(); i++) { + if (i > 0) r += ", "; + r += items[i]; + } + r += "]"; + return r; +} + +inline std::string parse_value(const std::string& s, size_t& p) { + skip_ws(s, p); + if (p >= s.size()) return ""; + if (s[p] == '"') return parse_string(s, p); + if (s[p] == '{') return parse_object(s, p); + if (s[p] == '[') return parse_array(s, p); + size_t start = p; + while (p < s.size() && s[p] != ',' && s[p] != '}' && s[p] != ']' && !std::isspace(static_cast(s[p]))) p++; + return s.substr(start, p - start); +} + +inline std::string reformat(const std::string& json) { + size_t p = 0; + return parse_value(json, p); +} + +} // namespace json_sorted + +inline std::string serialize_tools_for_template(const std::vector& tools) { + if (tools.empty()) return ""; + std::string result; + for (const auto& tool : tools) { + std::map func_fields; + func_fields["\"description\""] = "\"" + escape_json_string(tool.description) + "\""; + func_fields["\"name\""] = "\"" + escape_json_string(tool.name) + "\""; + auto it = tool.parameters.find("schema"); + if (it != tool.parameters.end()) { + func_fields["\"parameters\""] = json_sorted::reformat(it->second); + } + std::string func_json = "{"; + bool first = true; + for (const auto& kv : func_fields) { + if (!first) func_json += ", "; + func_json += kv.first + ": " + kv.second; + first = false; + } + func_json += "}"; + result += "\n{\"function\": " + func_json + ", \"type\": \"function\"}"; + } + return result; +} + +inline void handle_error_response(const std::string& error_message, char* response_buffer, size_t buffer_size) { + std::ostringstream json; + json << "{"; + json << "\"success\":false,"; + json << "\"error\":\"" << escape_json_string(error_message) << "\","; + json << "\"cloud_handoff\":false,"; + json << "\"response\":null,"; + json << "\"function_calls\":[],"; + json << "\"confidence\":0.0,"; + json << "\"time_to_first_token_ms\":0.0,"; + json << "\"total_time_ms\":0.0,"; + json << "\"prefill_tps\":0.0,"; + json << "\"decode_tps\":0.0,"; + json << "\"ram_usage_mb\":" << std::fixed << std::setprecision(2) << get_ram_usage_mb() << ","; + json << "\"prefill_tokens\":0,"; + json << "\"decode_tokens\":0,"; + json << "\"total_tokens\":0"; + json << "}"; + std::string error_json = json.str(); + if (response_buffer && error_json.length() < buffer_size) { + std::strcpy(response_buffer, error_json.c_str()); + } +} + +inline std::vector parse_messages_json(const std::string& json, + std::vector& out_image_paths, + std::vector* out_audio_paths = nullptr) { + std::vector messages; + out_image_paths.clear(); + if (out_audio_paths) out_audio_paths->clear(); + + size_t pos = json.find('['); + if (pos == std::string::npos) { + throw std::runtime_error("Invalid JSON: expected array"); + } + + pos = json.find('{', pos); + while (pos != std::string::npos) { + cactus::engine::ChatMessage msg; + + size_t obj_start = pos; + int brace_count = 1; + size_t obj_end = obj_start + 1; + while (obj_end < json.length() && brace_count > 0) { + if (json[obj_end] == '{') brace_count++; + else if (json[obj_end] == '}') brace_count--; + obj_end++; + } + + size_t role_pos = json.find("\"role\"", pos); + if (role_pos == std::string::npos || role_pos >= obj_end) break; + + size_t role_start = json.find('"', role_pos + 6) + 1; + size_t role_end = json.find('"', role_start); + msg.role = json.substr(role_start, role_end - role_start); + + size_t content_pos = json.find("\"content\"", role_end); + if (content_pos != std::string::npos && content_pos < obj_end) { + size_t content_start = json.find('"', content_pos + 9) + 1; + size_t content_end = content_start; + + while (content_end < json.length()) { + content_end = json.find('"', content_end); + if (content_end == std::string::npos) break; + if (json[content_end - 1] != '\\') break; + content_end++; + } + + msg.content = json.substr(content_start, content_end - content_start); + + size_t escape_pos = 0; + while ((escape_pos = msg.content.find("\\n", escape_pos)) != std::string::npos) { + msg.content.replace(escape_pos, 2, "\n"); + escape_pos += 1; + } + escape_pos = 0; + while ((escape_pos = msg.content.find("\\\"", escape_pos)) != std::string::npos) { + msg.content.replace(escape_pos, 2, "\""); + escape_pos += 1; + } + } + + auto parse_path_array = [&](const char* key, std::vector& dest, + std::vector* out_paths) { + size_t key_pos = json.find(key, pos); + if (key_pos == std::string::npos || key_pos >= obj_end) return; + size_t array_start = json.find('[', key_pos); + if (array_start == std::string::npos || array_start >= obj_end) return; + size_t array_end = json.find(']', array_start); + if (array_end == std::string::npos || array_end >= obj_end) return; + size_t cur = array_start; + while (true) { + cur = json.find('"', cur + 1); + if (cur == std::string::npos || cur >= array_end) break; + size_t str_start = cur + 1; + size_t str_end = json.find('"', str_start); + if (str_end == std::string::npos || str_end > array_end) break; + std::string path = std::filesystem::absolute( + std::filesystem::path(json.substr(str_start, str_end - str_start))).string(); + dest.push_back(path); + if (out_paths) out_paths->push_back(path); + cur = str_end; + } + }; + + parse_path_array("\"images\"", msg.images, &out_image_paths); + parse_path_array("\"audio\"", msg.audio, out_audio_paths); + + if (msg.role == "tool") { + size_t name_pos = json.find("\"name\"", obj_start); + if (name_pos != std::string::npos && name_pos < obj_end) { + size_t name_quote = json.find('"', name_pos + 6); + if (name_quote != std::string::npos && name_quote < obj_end) { + size_t name_start = name_quote + 1; + size_t name_end = json.find('"', name_start); + if (name_end != std::string::npos && name_end < obj_end) { + msg.name = json.substr(name_start, name_end - name_start); + } + } + } + } + + size_t tool_calls_pos = json.find("\"tool_calls\"", obj_start); + if (tool_calls_pos != std::string::npos && tool_calls_pos < obj_end) { + size_t tool_calls_arr_start = json.find('[', tool_calls_pos); + if (tool_calls_arr_start != std::string::npos && tool_calls_arr_start < obj_end) { + size_t tool_calls_arr_end = find_matching_delimiter(json, tool_calls_arr_start, '[', ']'); + + size_t search_pos = tool_calls_arr_start; + while (true) { + size_t func_pos = json.find("\"function\"", search_pos); + if (func_pos == std::string::npos || func_pos >= tool_calls_arr_end) break; + + size_t func_obj_start = json.find('{', func_pos + 10); + if (func_obj_start == std::string::npos || func_obj_start >= tool_calls_arr_end) break; + + size_t func_obj_end = find_matching_delimiter(json, func_obj_start, '{', '}'); + + cactus::engine::ToolCallInfo tool_call; + + size_t fn_name_pos = json.find("\"name\"", func_obj_start); + if (fn_name_pos != std::string::npos && fn_name_pos < func_obj_end) { + size_t fn_name_quote = json.find('"', fn_name_pos + 6); + if (fn_name_quote != std::string::npos && fn_name_quote < func_obj_end) { + size_t fn_name_start = fn_name_quote + 1; + size_t fn_name_end = json.find('"', fn_name_start); + if (fn_name_end != std::string::npos && fn_name_end < func_obj_end) { + tool_call.name = json.substr(fn_name_start, fn_name_end - fn_name_start); + } + } + } + + size_t args_pos = json.find("\"arguments\"", func_obj_start); + if (args_pos != std::string::npos && args_pos < func_obj_end) { + size_t colon_pos = json.find(':', args_pos + 11); + if (colon_pos != std::string::npos && colon_pos < func_obj_end) { + size_t args_start = colon_pos + 1; + while (args_start < json.length() && std::isspace(static_cast(json[args_start]))) args_start++; + + if (args_start < func_obj_end && json[args_start] == '{') { + size_t args_end = find_matching_delimiter(json, args_start, '{', '}'); + tool_call.arguments = json.substr(args_start, args_end - args_start); + } else if (args_start < func_obj_end && json[args_start] == '"') { + size_t str_start = args_start + 1; + size_t str_end = str_start; + while (str_end < json.length() && json[str_end] != '"') { + if (json[str_end] == '\\') str_end++; + str_end++; + } + tool_call.arguments = json.substr(str_start, str_end - str_start); + } + } + } + + if (!tool_call.name.empty()) { + msg.tool_calls.push_back(tool_call); + } + search_pos = func_obj_end; + } + } + } + + messages.push_back(msg); + + pos = json.find('{', obj_end); + } + + return messages; +} + +inline std::vector parse_tools_json(const std::string& json) { + std::vector tools; + + if (json.empty()) return tools; + + size_t pos = json.find('['); + if (pos == std::string::npos) return tools; + + pos = json.find("\"function\"", pos); + while (pos != std::string::npos) { + ToolFunction tool; + + size_t name_pos = json.find("\"name\"", pos); + if (name_pos != std::string::npos) { + size_t name_start = json.find('"', name_pos + 6) + 1; + size_t name_end = json.find('"', name_start); + tool.name = json.substr(name_start, name_end - name_start); + } + + size_t desc_pos = json.find("\"description\"", pos); + if (desc_pos != std::string::npos) { + size_t desc_start = json.find('"', desc_pos + 13) + 1; + size_t desc_end = json.find('"', desc_start); + tool.description = json.substr(desc_start, desc_end - desc_start); + } + + size_t params_pos = json.find("\"parameters\"", pos); + if (params_pos != std::string::npos) { + size_t params_start = json.find('{', params_pos); + if (params_start != std::string::npos) { + int brace_count = 1; + size_t params_end = params_start + 1; + while (params_end < json.length() && brace_count > 0) { + if (json[params_end] == '{') brace_count++; + else if (json[params_end] == '}') brace_count--; + params_end++; + } + tool.parameters["schema"] = json.substr(params_start, params_end - params_start); + } + } + + tools.push_back(tool); + + pos = json.find("\"function\"", name_pos); + } + + return tools; +} + +inline bool try_parse_json_float(const std::string& json, const std::string& key, float& out_value) { + std::string pattern = "\"" + key + "\":"; + size_t pos = json.find(pattern); + if (pos == std::string::npos) return false; + + size_t start = pos + pattern.size(); + while (start < json.size() && std::isspace(static_cast(json[start]))) ++start; + + size_t end = start; + while (end < json.size() && std::string(",}] \t\n\r").find(json[end]) == std::string::npos) ++end; + + try { + out_value = std::stof(json.substr(start, end - start)); + return true; + } catch (...) { + return false; + } +} + +inline std::vector parse_json_string_array_field(const std::string& json, const std::string& key) { + std::vector out; + std::string pattern = "\"" + key + "\":"; + size_t pos = json.find(pattern); + if (pos == std::string::npos) return out; + + size_t start = pos + pattern.size(); + while (start < json.size() && std::isspace(static_cast(json[start]))) ++start; + if (start >= json.size() || json[start] != '[') return out; + + int depth = 1; + bool in_string = false; + bool escaped = false; + size_t end = start + 1; + + while (end < json.size() && depth > 0) { + char c = json[end]; + if (in_string) { + if (escaped) escaped = false; + else if (c == '\\') escaped = true; + else if (c == '"') in_string = false; + } else { + if (c == '"') in_string = true; + else if (c == '[') depth++; + else if (c == ']') depth--; + } + ++end; + } + + if (depth != 0) return out; + const std::string array_json = json.substr(start, end - start); + if (array_json.size() < 2 || array_json.front() != '[' || array_json.back() != ']') return out; + + size_t i = 1; + while (i + 1 < array_json.size()) { + while (i + 1 < array_json.size() && + (std::isspace(static_cast(array_json[i])) || array_json[i] == ',')) { + ++i; + } + if (i + 1 >= array_json.size() || array_json[i] == ']') break; + if (array_json[i] != '"') break; + + ++i; + std::string value; + bool escaped = false; + while (i < array_json.size()) { + char c = array_json[i++]; + if (escaped) { + switch (c) { + case '"': value.push_back('"'); break; + case '\\': value.push_back('\\'); break; + case '/': value.push_back('/'); break; + case 'b': value.push_back('\b'); break; + case 'f': value.push_back('\f'); break; + case 'n': value.push_back('\n'); break; + case 'r': value.push_back('\r'); break; + case 't': value.push_back('\t'); break; + default: value.push_back(c); break; + } + escaped = false; + continue; + } + if (c == '\\') { + escaped = true; + continue; + } + if (c == '"') { + out.push_back(value); + break; + } + value.push_back(c); + } + } + + return out; +} + +inline void parse_custom_vocabulary_options(const std::string& json, + std::vector& custom_vocabulary, + float& vocabulary_boost) { + custom_vocabulary.clear(); + vocabulary_boost = 5.0f; + if (json.empty()) return; + + float parsed_boost = vocabulary_boost; + if (try_parse_json_float(json, "vocabulary_boost", parsed_boost)) { + vocabulary_boost = std::clamp(parsed_boost, 0.0f, 20.0f); + } + + custom_vocabulary = parse_json_string_array_field(json, "custom_vocabulary"); +} + +inline std::unordered_map build_token_bias_map(const std::vector>& tokenized_entries, + float vocabulary_boost) { + std::unordered_map vocab_bias; + const float clamped_boost = std::clamp(vocabulary_boost, 0.0f, 20.0f); + if (clamped_boost == 0.0f) return vocab_bias; + + for (const auto& token_ids : tokenized_entries) { + for (uint32_t token_id : token_ids) { + float& entry = vocab_bias[token_id]; + if (entry < clamped_boost) { + entry = clamped_boost; + } + } + } + + return vocab_bias; +} + +inline std::unordered_map build_custom_vocabulary_bias(cactus::engine::Tokenizer* tokenizer, + const std::vector& custom_vocabulary, + float vocabulary_boost) { + if (!tokenizer || custom_vocabulary.empty()) return {}; + std::vector> tokenized_entries; + tokenized_entries.reserve(custom_vocabulary.size()); + + for (const auto& word : custom_vocabulary) { + if (word.empty()) continue; + tokenized_entries.push_back(tokenizer->encode(word)); + } + + return build_token_bias_map(tokenized_entries, vocabulary_boost); +} + +inline void apply_custom_vocabulary_options(cactus::engine::Model* model, const std::string& json) { + if (!model) return; + + std::vector custom_vocabulary; + float vocabulary_boost = 5.0f; + parse_custom_vocabulary_options(json, custom_vocabulary, vocabulary_boost); + model->set_vocab_bias(build_custom_vocabulary_bias(model->get_tokenizer(), custom_vocabulary, vocabulary_boost)); +} + +inline size_t levenshtein_ci(const std::string& a, const std::string& b) { + const size_t m = a.size(), n = b.size(); + std::vector prev(n + 1), curr(n + 1); + for (size_t j = 0; j <= n; ++j) prev[j] = j; + for (size_t i = 1; i <= m; ++i) { + curr[0] = i; + for (size_t j = 1; j <= n; ++j) { + const bool match = std::tolower(static_cast(a[i - 1])) == + std::tolower(static_cast(b[j - 1])); + curr[j] = std::min({prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + (match ? 0 : 1)}); + } + std::swap(prev, curr); + } + return prev[n]; +} + +inline std::string collapse_spaces(const std::string& s) { + std::string out; + out.reserve(s.size()); + for (char c : s) { + if (c != ' ') out += c; + } + return out; +} + +inline void apply_vocabulary_spelling_correction( + std::string& text, + const std::vector& custom_vocabulary) +{ + if (custom_vocabulary.empty() || text.empty()) return; + + struct VocabEntry { + const std::string* original; + std::string collapsed; + }; + std::vector vocab_entries; + vocab_entries.reserve(custom_vocabulary.size()); + for (const auto& v : custom_vocabulary) { + vocab_entries.push_back({&v, collapse_spaces(v)}); + } + + struct Token { std::string text; bool is_word; }; + std::vector tokens; + size_t pos = 0; + while (pos < text.size()) { + if (std::isalnum(static_cast(text[pos])) || + text[pos] == '\'' || text[pos] == '-') { + size_t start = pos; + while (pos < text.size() && (std::isalnum(static_cast(text[pos])) || + text[pos] == '\'' || text[pos] == '-')) { + ++pos; + } + tokens.push_back({text.substr(start, pos - start), true}); + } else { + size_t start = pos; + while (pos < text.size() && !std::isalnum(static_cast(text[pos])) && + text[pos] != '\'' && text[pos] != '-') { + ++pos; + } + tokens.push_back({text.substr(start, pos - start), false}); + } + } + + std::vector word_indices; + for (size_t i = 0; i < tokens.size(); ++i) { + if (tokens[i].is_word) word_indices.push_back(i); + } + + std::vector consumed(tokens.size(), false); + + auto strip_suffix = [](const std::string& word) -> std::pair { + if (word.size() >= 3 && word.substr(word.size() - 2) == "'s") { + return {word.substr(0, word.size() - 2), "'s"}; + } + if (word.size() >= 3 && word.substr(word.size() - 2) == "'t") { + return {word.substr(0, word.size() - 2), "'t"}; + } + if (word.size() >= 4 && word.back() == 's' && + word[word.size() - 2] != 's' && // avoid stripping from "boss", "class" + std::isalpha(static_cast(word[word.size() - 2]))) { + return {word.substr(0, word.size() - 1), "s"}; + } + return {word, ""}; + }; + + size_t wi = 0; + while (wi < word_indices.size()) { + size_t best_dist = std::numeric_limits::max(); + const std::string* best_match = nullptr; + size_t best_window = 0; + size_t best_first_token = 0; + size_t best_last_token = 0; + std::string best_suffix; + + for (size_t window = std::min(3, word_indices.size() - wi); window >= 1; --window) { + std::string window_collapsed; + const size_t first_tok = word_indices[wi]; + const size_t last_tok = word_indices[wi + window - 1]; + for (size_t w = 0; w < window; ++w) { + window_collapsed += tokens[word_indices[wi + w]].text; + } + + if (window == 1 && window_collapsed.size() < 3) break; + + auto [stem, suffix] = strip_suffix(window_collapsed); + const std::string* candidates[] = {&window_collapsed, &stem}; + const std::string suffixes[] = {"", suffix}; + const size_t num_candidates = suffix.empty() ? 1 : 2; + + for (size_t ci = 0; ci < num_candidates; ++ci) { + const std::string& candidate = *candidates[ci]; + if (candidate.empty()) continue; + + for (const auto& entry : vocab_entries) { + const size_t wlen = candidate.size(); + const size_t vlen = entry.collapsed.size(); + + const size_t len_diff = wlen > vlen ? wlen - vlen : vlen - wlen; + const size_t max_dist = std::max(1, std::min(wlen, vlen) / 3); + if (len_diff > max_dist) continue; + + const size_t dist = levenshtein_ci(candidate, entry.collapsed); + + // For single-edit corrections, require first char match to prevent + // false positives like "vortex" → "Cortex". + if (dist == 1 && window == 1) { + const bool first_char_match = + std::tolower(static_cast(candidate[0])) == + std::tolower(static_cast(entry.collapsed[0])); + if (!first_char_match) continue; + } + + if (dist <= max_dist && dist < best_dist) { + best_dist = dist; + best_match = entry.original; + best_window = window; + best_first_token = first_tok; + best_last_token = last_tok; + best_suffix = suffixes[ci]; + } + } + } + + if (best_dist == 0) break; + } + + // Allow dist==0 for multi-word merges where word boundaries changed. + const bool should_replace = best_match && + best_dist != std::numeric_limits::max() && + (best_dist > 0 || best_window > 1); + + if (should_replace) { + tokens[best_first_token].text = *best_match + best_suffix; + for (size_t t = best_first_token + 1; t <= best_last_token; ++t) { + consumed[t] = true; + } + for (size_t t = best_first_token + 1; t <= best_last_token; ++t) { + if (t > 0) consumed[t - 1] = consumed[t - 1] || !tokens[t - 1].is_word; + } + wi += best_window; + } else { + ++wi; + } + } + + std::string result; + result.reserve(text.size()); + for (size_t i = 0; i < tokens.size(); ++i) { + if (!consumed[i]) { + result += tokens[i].text; + } + } + + text = std::move(result); +} + +inline InferenceOptions parse_inference_options_json(const std::string& json) { + InferenceOptions options; + + if (json.empty()) return options; + + size_t pos = json.find("\"temperature\""); + if (pos != std::string::npos) { + pos = json.find(':', pos) + 1; + options.temperature = std::stof(json.substr(pos)); + } + + pos = json.find("\"top_p\""); + if (pos != std::string::npos) { + pos = json.find(':', pos) + 1; + options.top_p = std::stof(json.substr(pos)); + } + + float parsed_min_p = options.min_p; + if (try_parse_json_float(json, "min_p", parsed_min_p)) { + options.min_p = std::clamp(parsed_min_p, 0.0f, 1.0f); + } + + float parsed_rep_penalty = options.repetition_penalty; + if (try_parse_json_float(json, "repetition_penalty", parsed_rep_penalty)) { + if (std::isfinite(parsed_rep_penalty) && parsed_rep_penalty > 0.0f) { + options.repetition_penalty = parsed_rep_penalty; + } + } + + pos = json.find("\"top_k\""); + if (pos != std::string::npos) { + pos = json.find(':', pos) + 1; + options.top_k = std::stoul(json.substr(pos)); + } + + pos = json.find("\"max_tokens\""); + if (pos != std::string::npos) { + pos = json.find(':', pos) + 1; + options.max_tokens = std::stoul(json.substr(pos)); + } + + pos = json.find("\"force_tools\""); + if (pos != std::string::npos) { + pos = json.find(':', pos) + 1; + while (pos < json.length() && std::isspace(static_cast(json[pos]))) pos++; + options.force_tools = (json.substr(pos, 4) == "true"); + } + + pos = json.find("\"tool_rag_top_k\""); + if (pos != std::string::npos) { + pos = json.find(':', pos) + 1; + options.tool_rag_top_k = std::stoul(json.substr(pos)); + } + + pos = json.find("\"confidence_threshold\""); + if (pos != std::string::npos) { + pos = json.find(':', pos) + 1; + options.confidence_threshold = std::stof(json.substr(pos)); + } + + pos = json.find("\"include_stop_sequences\""); + if (pos != std::string::npos) { + pos = json.find(':', pos) + 1; + while (pos < json.length() && std::isspace(static_cast(json[pos]))) pos++; + options.include_stop_sequences = (json.substr(pos, 4) == "true"); + } + + pos = json.find("\"use_vad\""); + if (pos != std::string::npos) { + pos = json.find(':', pos) + 1; + while (pos < json.length() && std::isspace(static_cast(json[pos]))) pos++; + options.use_vad = (json.substr(pos, 4) == "true"); + } + + pos = json.find("\"telemetry_enabled\""); + if (pos != std::string::npos) { + pos = json.find(':', pos) + 1; + while (pos < json.length() && std::isspace(static_cast(json[pos]))) pos++; + options.telemetry_enabled = (json.substr(pos, 4) == "true"); + } + + pos = json.find("\"auto_handoff\""); + if (pos != std::string::npos) { + pos = json.find(':', pos) + 1; + while (pos < json.length() && std::isspace(static_cast(json[pos]))) pos++; + options.auto_handoff = (json.substr(pos, 4) == "true"); + } + + pos = json.find("\"cloud_timeout_ms\""); + if (pos != std::string::npos) { + pos = json.find(':', pos) + 1; + options.cloud_timeout_ms = std::stoul(json.substr(pos)); + } + + pos = json.find("\"handoff_with_images\""); + if (pos != std::string::npos) { + pos = json.find(':', pos) + 1; + while (pos < json.length() && std::isspace(static_cast(json[pos]))) pos++; + options.handoff_with_images = (json.substr(pos, 4) == "true"); + } + + pos = json.find("\"enable_thinking_if_supported\""); + if (pos != std::string::npos) { + pos = json.find(':', pos) + 1; + while (pos < json.length() && std::isspace(static_cast(json[pos]))) pos++; + options.enable_thinking_if_supported = (json.substr(pos, 4) == "true"); + } + + pos = json.find("\"stop_sequences\""); + if (pos != std::string::npos) { + pos = json.find('[', pos); + if (pos != std::string::npos) { + size_t end_pos = json.find(']', pos); + size_t seq_pos = json.find('"', pos); + + while (seq_pos != std::string::npos && seq_pos < end_pos) { + size_t seq_start = seq_pos + 1; + size_t seq_end = json.find('"', seq_start); + if (seq_end != std::string::npos) { + options.stop_sequences.push_back(json.substr(seq_start, seq_end - seq_start)); + } + seq_pos = json.find('"', seq_end + 1); + } + } + } + + return options; +} + +static inline std::string trim_lfm2_slice(const std::string& value, size_t begin, size_t end) { + return trim_string(value.substr(begin, end - begin)); +} + +static inline void append_lfm2_call(const std::string& entry, + std::vector& function_calls) { + if (entry.empty()) return; + + std::string trimmed_entry = trim_lfm2_slice(entry, 0, entry.size()); + if (trimmed_entry.empty()) return; + + size_t paren_pos = trimmed_entry.find('('); + if (paren_pos == std::string::npos) return; + + std::string func_name = trim_lfm2_slice(trimmed_entry, 0, paren_pos); + std::string args_str = trim_lfm2_slice(trimmed_entry, paren_pos + 1, trimmed_entry.size()); + + if (!args_str.empty() && args_str.back() == ')') { + args_str.pop_back(); + args_str = trim_lfm2_slice(args_str, 0, args_str.size()); + } + + std::string json_call = "{\"name\":\"" + func_name + "\",\"arguments\":{"; + + size_t arg_pos = 0; + bool first_arg = true; + while (arg_pos < args_str.length()) { + while (arg_pos < args_str.length() && std::isspace(static_cast(args_str[arg_pos]))) { + arg_pos++; + } + + size_t eq_pos = args_str.find('=', arg_pos); + if (eq_pos == std::string::npos) break; + + std::string arg_name = args_str.substr(arg_pos, eq_pos - arg_pos); + + size_t val_start = eq_pos + 1; + size_t val_end = val_start; + + if (val_start < args_str.length() && args_str[val_start] == '"') { + val_start++; + val_end = args_str.find('"', val_start); + if (val_end == std::string::npos) break; + } else { + val_end = args_str.find(',', val_start); + if (val_end == std::string::npos) val_end = args_str.length(); + } + + std::string arg_value = args_str.substr(val_start, val_end - val_start); + + if (!first_arg) json_call += ","; + json_call += "\"" + arg_name + "\":\"" + arg_value + "\""; + first_arg = false; + + arg_pos = args_str.find(',', val_end); + if (arg_pos != std::string::npos) { + arg_pos++; + } else { + break; + } + } + + json_call += "}}"; + function_calls.push_back(json_call); +} + +inline void parse_function_calls_from_response(const std::string& response_text, + std::string& regular_response, + std::vector& function_calls) { + regular_response = response_text; + function_calls.clear(); + + gemma::parse_function_calls(regular_response, function_calls); + + const std::string QWEN_TOOL_START = ""; + const std::string QWEN_TOOL_END = ""; + size_t qwen_start_pos = 0; + + while ((qwen_start_pos = regular_response.find(QWEN_TOOL_START, qwen_start_pos)) != std::string::npos) { + size_t content_start = qwen_start_pos + QWEN_TOOL_START.length(); + size_t qwen_end_pos = regular_response.find(QWEN_TOOL_END, content_start); + + size_t erase_end; + std::string json_content; + + if (qwen_end_pos != std::string::npos) { + json_content = regular_response.substr(content_start, qwen_end_pos - content_start); + erase_end = qwen_end_pos + QWEN_TOOL_END.length(); + } else { + json_content = regular_response.substr(content_start); + erase_end = regular_response.length(); + } + + size_t first = json_content.find_first_not_of(" \t\n\r"); + size_t last = json_content.find_last_not_of(" \t\n\r"); + if (first != std::string::npos && last != std::string::npos) { + json_content = json_content.substr(first, last - first + 1); + } + + if (json_content.size() > 2 && json_content.find("\"name\"") != std::string::npos) { + // Unwrap array wrapper if present: [{"name":...}] -> {"name":...} + if (json_content[0] == '[') { + size_t obj_start = json_content.find('{'); + size_t obj_end = json_content.rfind('}'); + if (obj_start != std::string::npos && obj_end != std::string::npos && obj_end > obj_start) { + json_content = json_content.substr(obj_start, obj_end - obj_start + 1); + } + } + if (json_content[0] == '{') { + size_t depth = 0; + bool in_string = false; + bool escaped = false; + size_t end_pos = 0; + for (size_t c = 0; c < json_content.size(); c++) { + char ch = json_content[c]; + if (escaped) { escaped = false; continue; } + if (ch == '\\' && in_string) { escaped = true; continue; } + if (ch == '"') { in_string = !in_string; continue; } + if (!in_string) { + if (ch == '{') depth++; + else if (ch == '}' && --depth == 0) { end_pos = c + 1; break; } + } + } + if (end_pos > 0) { + function_calls.push_back(json_content.substr(0, end_pos)); + } + } + } + + regular_response.erase(qwen_start_pos, erase_end - qwen_start_pos); + } + + const std::string TOOL_CALL_START = "<|tool_call_start|>"; + const std::string TOOL_CALL_END = "<|tool_call_end|>"; + size_t lfm2_start_pos = 0; + + while ((lfm2_start_pos = regular_response.find(TOOL_CALL_START, lfm2_start_pos)) != std::string::npos) { + size_t content_start = lfm2_start_pos + TOOL_CALL_START.length(); + size_t tool_end_pos = regular_response.find(TOOL_CALL_END, content_start); + + if (tool_end_pos != std::string::npos) { + std::string tool_content = regular_response.substr(content_start, tool_end_pos - content_start); + std::string content = tool_content; + size_t trim_start = 0; + while (trim_start < content.size() && std::isspace(static_cast(content[trim_start]))) { + trim_start++; + } + + if (trim_start < content.size()) { + size_t trim_end = content.size() - 1; + while (trim_end > trim_start && std::isspace(static_cast(content[trim_end]))) { + trim_end--; + } + content = content.substr(trim_start, trim_end - trim_start + 1); + } else { + content.clear(); + } + + if (!content.empty() && content.front() == '[' && content.back() == ']') { + std::string inner = content.substr(1, content.size() - 2); + + size_t inner_first = inner.find_first_not_of(" \t\n\r"); + if (inner_first != std::string::npos && inner[inner_first] == '{') { + size_t pos = inner_first; + while (pos < inner.size()) { + if (inner[pos] == '{') { + int brace_depth = 1; + size_t obj_start = pos; + pos++; + while (pos < inner.size() && brace_depth > 0) { + if (inner[pos] == '{') brace_depth++; + else if (inner[pos] == '}') brace_depth--; + pos++; + } + if (brace_depth == 0) { + std::string json_obj = inner.substr(obj_start, pos - obj_start); + if (json_obj.find("\"name\"") != std::string::npos) { + function_calls.push_back(json_obj); + } + } + } else { + pos++; + } + } + } else { + size_t start = 0; + int paren_depth = 0; + + for (size_t i = 0; i < inner.size(); ++i) { + char c = inner[i]; + if (c == '(') { + paren_depth++; + } else if (c == ')' && paren_depth > 0) { + paren_depth--; + } else if (c == ',' && paren_depth == 0) { + append_lfm2_call(inner.substr(start, i - start), function_calls); + start = i + 1; + } + } + + if (start < inner.size()) { + append_lfm2_call(inner.substr(start), function_calls); + } + } + } else if (!content.empty()) { + append_lfm2_call(content, function_calls); + } + + regular_response.erase(lfm2_start_pos, tool_end_pos + TOOL_CALL_END.length() - lfm2_start_pos); + } else { + break; + } + } + + const char* FUNCTION_CALL_MARKER = "\"function_call\""; + size_t search_pos = 0; + const size_t text_len = regular_response.length(); + + while (search_pos < text_len) { + size_t marker_pos = regular_response.find(FUNCTION_CALL_MARKER, search_pos); + if (marker_pos == std::string::npos) break; + + size_t json_start = regular_response.find('{', marker_pos); + if (json_start == std::string::npos) break; + + int brace_count = 1; + size_t json_end = json_start + 1; + while (json_end < text_len && brace_count > 0) { + char c = regular_response[json_end]; + brace_count += (c == '{') - (c == '}'); + json_end++; + } + + if (brace_count == 0) { + function_calls.push_back(regular_response.substr(json_start, json_end - json_start)); + regular_response = regular_response.substr(0, marker_pos); + size_t last_bracket = regular_response.rfind('{'); + if(last_bracket != std::string::npos) { + regular_response = regular_response.substr(0, last_bracket); + } + } + search_pos = json_end; + } +} + +inline std::vector> find_channel_token_ranges( + const std::vector& tokens, size_t offset, + uint32_t channel_open_id, uint32_t channel_close_id) { + std::vector> ranges; + size_t pos = 0; + while (pos < tokens.size()) { + if (tokens[pos] != channel_open_id) { + pos++; + continue; + } + + size_t block_start = pos; + pos++; + while (pos < tokens.size() && tokens[pos] != channel_close_id) { + pos++; + } + if (pos < tokens.size()) { + pos++; + } + ranges.push_back({offset + block_start, pos - block_start}); + } + return ranges; +} + +inline void strip_tag_blocks(std::string& text, std::string& extracted, + const std::string& open_tag, const std::string& close_tag) { + std::string result; + size_t pos = 0; + + size_t first_close = text.find(close_tag); + size_t first_open = text.find(open_tag); + if (first_close != std::string::npos && + (first_open == std::string::npos || first_close < first_open)) { + extracted += text.substr(0, first_close); + pos = first_close + close_tag.size(); + } + + while (pos < text.size()) { + size_t open_pos = text.find(open_tag, pos); + if (open_pos == std::string::npos) { + result += text.substr(pos); + break; + } + result += text.substr(pos, open_pos - pos); + size_t content_start = open_pos + open_tag.size(); + size_t close_pos = text.find(close_tag, content_start); + if (close_pos == std::string::npos) { + if (!extracted.empty()) extracted += "\n"; + extracted += text.substr(content_start); + break; + } + if (!extracted.empty()) extracted += "\n"; + extracted += text.substr(content_start, close_pos - content_start); + pos = close_pos + close_tag.size(); + } + text = result; +} + +inline void strip_thinking_block(const std::string& input, std::string& thinking, std::string& content) { + thinking.clear(); + content = input; + + auto trim = [](std::string& s) { + size_t first = s.find_first_not_of(" \t\n\r"); + size_t last = s.find_last_not_of(" \t\n\r"); + if (first != std::string::npos && last != std::string::npos) + s = s.substr(first, last - first + 1); + else + s.clear(); + }; + + if (content.find("<|channel>") != std::string::npos || content.find("") != std::string::npos) { + strip_tag_blocks(content, thinking, "<|channel>", ""); + } else if (content.find("") != std::string::npos || content.find("") != std::string::npos) { + strip_tag_blocks(content, thinking, "", ""); + } else { + return; + } + + trim(thinking); + trim(content); +} + +struct TranscriptSegment { + float start; + float end; + std::string text; +}; + +inline std::string construct_response_json(const std::string& regular_response, + const std::vector& function_calls, + double time_to_first_token, + double total_time_ms, + double prefill_tps, + double decode_tps, + size_t prompt_tokens, + size_t completion_tokens, + float confidence = 0.0f, + bool cloud_handoff = false, + const std::string& thinking = "", + const std::vector& segments = {}) { + std::ostringstream json; + json << "{"; + json << "\"success\":true,"; + json << "\"error\":null,"; + json << "\"cloud_handoff\":" << (cloud_handoff ? "true" : "false") << ","; + json << "\"response\":\"" << escape_json_string(regular_response) << "\","; + if (!thinking.empty()) { + json << "\"thinking\":\"" << escape_json_string(thinking) << "\","; + } + json << "\"function_calls\":["; + for (size_t i = 0; i < function_calls.size(); ++i) { + if (i > 0) json << ","; + json << function_calls[i]; + } + json << "],"; + json << "\"segments\":["; + for (size_t i = 0; i < segments.size(); ++i) { + if (i > 0) json << ","; + json << "{\"start\":" << std::fixed << std::setprecision(3) << segments[i].start + << ",\"end\":" << std::fixed << std::setprecision(3) << segments[i].end + << ",\"text\":\"" << escape_json_string(segments[i].text) << "\"}"; + } + json << "],"; + json << "\"confidence\":" << std::fixed << std::setprecision(4) << confidence << ","; + json << "\"time_to_first_token_ms\":" << std::fixed << std::setprecision(2) << time_to_first_token << ","; + json << "\"total_time_ms\":" << std::fixed << std::setprecision(2) << total_time_ms << ","; + json << "\"prefill_tps\":" << std::fixed << std::setprecision(2) << prefill_tps << ","; + json << "\"decode_tps\":" << std::fixed << std::setprecision(2) << decode_tps << ","; + json << "\"ram_usage_mb\":" << std::fixed << std::setprecision(2) << get_ram_usage_mb() << ","; + json << "\"prefill_tokens\":" << prompt_tokens << ","; + json << "\"decode_tokens\":" << completion_tokens << ","; + json << "\"total_tokens\":" << (prompt_tokens + completion_tokens); + json << "}"; + return json.str(); +} + +inline std::string serialize_function_calls(const std::vector& calls) { + if (calls.empty()) return "[]"; + std::ostringstream oss; + oss << "["; + for (size_t i = 0; i < calls.size(); ++i) { + if (i > 0) oss << ","; + oss << calls[i]; + } + oss << "]"; + return oss.str(); +} + +inline int validate_audio_params( + const char* component, + void* model, + char* response_buffer, size_t buffer_size, + const char* audio_file_path, + const uint8_t* pcm_buffer, size_t pcm_buffer_size) { + if (!model) { + std::string err = last_error_message.empty() ? "Model not initialized." : last_error_message; + CACTUS_LOG_ERROR(component, err); + handle_error_response(err, response_buffer, buffer_size); + return -1; + } + if (!response_buffer || buffer_size == 0) { + CACTUS_LOG_ERROR(component, "Invalid parameters: response_buffer or buffer_size"); + handle_error_response("Invalid parameters", response_buffer, buffer_size); + return -1; + } + if (!audio_file_path && (!pcm_buffer || pcm_buffer_size == 0)) { + CACTUS_LOG_ERROR(component, "No audio input provided"); + handle_error_response("Either audio_file_path or pcm_buffer must be provided", response_buffer, buffer_size); + return -1; + } + if (audio_file_path && pcm_buffer && pcm_buffer_size > 0) { + CACTUS_LOG_ERROR(component, "Both audio_file_path and pcm_buffer provided"); + handle_error_response("Cannot provide both audio_file_path and pcm_buffer", response_buffer, buffer_size); + return -1; + } + if (pcm_buffer && pcm_buffer_size > 0 && (pcm_buffer_size < 2 || pcm_buffer_size % 2 != 0)) { + CACTUS_LOG_ERROR(component, "Invalid pcm_buffer_size"); + handle_error_response("pcm_buffer_size must be even and at least 2 bytes", response_buffer, buffer_size); + return -1; + } + return 0; +} + +inline std::vector pcm_to_float(const uint8_t* pcm_buffer, size_t pcm_buffer_size) { + const int16_t* samples = reinterpret_cast(pcm_buffer); + size_t n = pcm_buffer_size / 2; + std::vector out(n); + for (size_t i = 0; i < n; ++i) + out[i] = static_cast(samples[i]) / 32768.0f; + return out; +} + +} // namespace ffi +} // namespace cactus + +#ifdef __cplusplus +extern "C" { +#endif + +const char* cactus_get_last_error(); + +#ifdef __cplusplus +} +#endif + +#endif // CACTUS_UTILS_H diff --git a/Frameworks/cactus-ios.xcframework/ios-arm64/cactus.framework/Headers/engine.h b/Frameworks/cactus-ios.xcframework/ios-arm64/cactus.framework/Headers/engine.h new file mode 100644 index 00000000..8b9d3f6c --- /dev/null +++ b/Frameworks/cactus-ios.xcframework/ios-arm64/cactus.framework/Headers/engine.h @@ -0,0 +1,1079 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "../graph/graph.h" + +#ifdef __clang__ +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wc99-extensions" +#pragma clang diagnostic ignored "-Wunused-parameter" +#elif defined(__GNUC__) +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wpedantic" +#pragma GCC diagnostic ignored "-Wunused-parameter" +#endif + +extern "C" { + #include "../../libs/stb/stb_image.h" + #include "../../libs/stb/stb_image_resize2.h" +} + +#ifdef __clang__ +#pragma clang diagnostic pop +#elif defined(__GNUC__) +#pragma GCC diagnostic pop +#endif + +class CactusGraph; + +namespace cactus { +namespace npu { + class NPUPrefill; +} +namespace engine { + +class Siglip2Preprocessor; + +struct Config { + uint32_t vocab_size = 151936; + uint32_t bos_token_id = 151643; + uint32_t eos_token_id = 151645; + uint32_t num_layers = 28; + uint32_t hidden_dim = 1024; + uint32_t ffn_intermediate_dim = 3072; + uint32_t attention_heads = 16; + uint32_t attention_kv_heads = 8; + uint32_t attention_head_dim = 128; + float layer_norm_eps = 1e-6f; + float rope_theta = 1000000.0f; + uint32_t num_experts = 0; + uint32_t num_shared_experts = 0; + uint32_t num_top_experts = 0; + uint32_t moe_every_n_layers = 0; + uint32_t moe_intermediate_dim = 0; + uint32_t num_dense_layers = 0; + uint32_t num_experts_per_tok = 0; + bool norm_topk_prob = false; + bool use_expert_bias = false; + float routed_scaling_factor = 1.0f; + bool tie_word_embeddings = true; + + uint32_t vision_hidden_dim = 0; + uint32_t vision_num_layers = 0; + uint32_t vision_attention_heads = 0; + uint32_t vision_image_size = 0; + uint32_t vision_patch_size = 0; + uint32_t vision_num_channels = 3; + uint32_t vision_embed_dim = 0; + uint32_t visual_tokens_per_img = 0; + bool use_pixel_shuffle = false; + uint32_t pixel_shuffle_factor = 1; + bool use_image_tokens = false; + uint32_t image_token_id = 0; + bool use_layout_tags = false; + uint32_t image_seq_len = 64; + + uint32_t global_image_size = 2048; + uint32_t max_tile_size = 512; + float rescale_factor = 0.00392156862745098f; + float image_mean = 0.5f; + float image_std = 0.5f; + + uint32_t downsample_factor = 2; + uint32_t min_tiles = 2; + uint32_t max_tiles = 10; + bool use_thumbnail = true; + uint32_t min_image_tokens = 64; + uint32_t max_image_tokens = 256; + uint32_t max_num_patches = 1024; + uint32_t tile_size = 512; + float max_pixels_tolerance = 2.0f; + bool do_image_splitting = true; + bool encoder_act_gelu = false; + bool decoder_act_gelu = false; + uint32_t num_encoder_layers = 0; + uint32_t num_decoder_layers = 0; + float partial_rotary_factor = 0.0f; + uint32_t pad_token_id = 0; + uint32_t conv_kernel_size = 0; + uint32_t subsampling_conv_kernel_size = 0; + uint32_t subsampling_conv_stride = 0; + uint32_t subsampling_conv_channels = 0; + uint32_t subsampling_factor = 0; + uint32_t num_mel_bins = 80; + std::string encoder_hidden_act = "silu"; + uint32_t linear_num_key_heads = 0; + uint32_t linear_key_head_dim = 0; + uint32_t linear_num_value_heads = 0; + uint32_t linear_value_head_dim = 0; + uint32_t linear_q_proj_dim = 0; + uint32_t linear_k_proj_dim = 0; + uint32_t linear_v_proj_dim = 0; + + uint32_t kv_lora_rank = 0; + uint32_t q_lora_rank = 0; + uint32_t qk_head_dim = 0; + uint32_t qk_nope_head_dim = 0; + uint32_t qk_rope_head_dim = 0; + uint32_t v_head_dim = 0; + uint32_t rope_interleave = 0; + bool attention_bias = false; + float rope_scaling_factor = 1.0f; + float rope_mscale_all_dim = 0.0f; + + enum class ModelType {QWEN = 0, GEMMA = 1, NOMIC = 3, LFM2 = 5, SIGLIP2 = 6, WHISPER = 7, MOONSHINE = 8, SILERO_VAD = 9, PARAKEET = 10, QWEN3P5 = 11, PARAKEET_TDT = 12, GEMMA3N = 13, YOUTU = 14, GEMMA4 = 15, PYANNOTE = 16, WESPEAKER = 17, NEEDLE = 18}; + uint32_t predictor_hidden_dim = 0; + uint32_t predictor_num_layers = 0; + uint32_t tdt_joint_dim = 0; + uint32_t tdt_num_durations = 0; + uint32_t tdt_blank_id = 0; + std::vector tdt_durations; + + ModelType model_type = ModelType::QWEN; + + enum class ModelVariant {DEFAULT = 0, VLM = 1, EXTRACT = 2, RAG = 3}; + ModelVariant model_variant = ModelVariant::DEFAULT; + + enum class Activation {GELU = 0, SILU = 1}; + Activation activation = Activation::SILU; + + enum class Backend {CPU = 0, NPU = 1}; + Backend default_backend = Backend::CPU; + + enum class Precision {INT8 = 0, FP16 = 1, FP32 = 2}; + Precision precision = Precision::FP32; + + float default_temperature = 0.6f; + float default_top_p = 0.95f; + size_t default_top_k = 20; + float default_max_tps = -1.0f; + float default_cloud_handoff_threshold = 0.0f; + + std::vector layer_types; + size_t conv_L_cache = 0; + + uint32_t altup_num_inputs = 4; + uint32_t laurel_rank = 64; + uint32_t hidden_size_per_layer_input = 256; + uint32_t num_kv_shared_layers = 0; + uint32_t sliding_window = 512; + float rope_local_base_freq = 10000.0f; + float final_logit_softcapping = 0.0f; + float global_partial_rotary_factor = 1.0f; + uint32_t expert_intermediate_size = 0; + uint32_t global_head_dim = 0; + uint32_t num_global_kv_heads = 0; + bool attention_k_eq_v = false; + bool enable_moe_block = false; + std::vector activation_sparsity_ppf; + + uint32_t vision_head_dim = 64; + uint32_t vision_kv_heads = 12; + uint32_t vision_intermediate_size = 3072; + uint32_t vision_position_embedding_size = 10240; + uint32_t vision_pooling_kernel_size = 3; + uint32_t vision_default_output_length = 280; + float vision_rope_theta = 100.0f; + + uint32_t audio_hidden_dim = 0; + uint32_t audio_num_layers = 0; + uint32_t audio_num_heads = 0; + uint32_t audio_head_dim = 0; + uint32_t audio_input_feat_size = 128; + uint32_t audio_conf_conv_kernel_size = 5; + uint32_t audio_chunk_size = 12; + uint32_t audio_context_left = 13; + uint32_t audio_context_right = 0; + float audio_logit_cap = 50.0f; + float audio_residual_weight = 0.5f; + uint32_t audio_output_proj_dims = 0; + uint32_t audio_vocab_size = 128; + uint32_t audio_vocab_offset = 0; + uint32_t audio_soft_tokens = 188; + uint32_t audio_sscp_conv0_channels = 128; + uint32_t audio_sscp_conv1_channels = 32; + float audio_sscp_conv_eps = 1e-3f; + float audio_rms_norm_eps = 1e-6f; + uint32_t audio_fft_length = 1024; + uint32_t audio_token_id = 0; + bool audio_fft_overdrive = false; + uint32_t channel_open_token_id = 100; + uint32_t channel_close_token_id = 101; + + static bool is_gemma_family(ModelType t) { + return t == ModelType::GEMMA || t == ModelType::GEMMA3N || t == ModelType::GEMMA4; + } + + bool from_json(const std::string& json_path); + std::string to_json() const; +}; + + + +struct MergeRule { + std::string first; + std::string second; + std::string merged; + uint32_t priority; + + MergeRule(const std::string& f, const std::string& s, const std::string& m, uint32_t p) + : first(f), second(s), merged(m), priority(p) {} +}; + + +struct ToolCallInfo { + std::string name; + std::string arguments; +}; + +struct ChatMessage { + std::string role; + std::string content; + std::string name; + std::vector images; + std::vector audio; + size_t audio_soft_token_count = 0; + std::vector tool_calls; +}; + +inline std::string format_needle_query_text(const std::vector& messages) { + std::string system_text; + std::string user_query; + + for (const auto& msg : messages) { + if (msg.role == "system") { + if (!system_text.empty()) { + system_text += "\n"; + } + system_text += msg.content; + } else if (msg.role == "user") { + user_query = msg.content; + } + } + + if (user_query.empty() && !messages.empty()) { + user_query = messages.back().content; + } + if (system_text.empty()) { + return user_query; + } + if (user_query.empty()) { + return system_text; + } + return system_text + "\n\n" + user_query; +} + +struct ToolConstraintSpec { + std::string name; + std::vector parameter_names; + std::vector required_parameter_names; +}; + +struct TokenizerRuntimeConfig { + enum class TokenizerType { UNKNOWN, BPE, SENTENCEPIECE }; + enum class VocabFormat { UNKNOWN, ID_TAB_TOKEN, LINE_TOKEN }; + enum class Normalizer { NONE, METASPACE, BYTE_LEVEL }; + enum class Decoder { NONE, REPLACE_METASPACE, BYTE_LEVEL }; + + TokenizerType tokenizer_type = TokenizerType::UNKNOWN; + VocabFormat vocab_format = VocabFormat::UNKNOWN; + Normalizer normalizer = Normalizer::NONE; + Decoder decoder = Decoder::NONE; + bool byte_fallback = false; + bool has_chat_template = false; +}; + +TokenizerRuntimeConfig load_tokenizer_runtime_config(const std::string& config_file); +void load_special_tokens_map(const std::string& config_file, std::unordered_map& special_tokens); +std::vector split_with_special_tokens(const std::string& text, const std::unordered_map& special_tokens); + +class Tokenizer { +public: + virtual ~Tokenizer() = default; + + virtual std::vector encode(const std::string& text) const = 0; + virtual std::string decode(const std::vector& tokens) const = 0; + + virtual std::vector apply_chat_template(const std::vector& messages, bool add_generation_prompt = true) const; + virtual std::string format_chat_prompt(const std::vector& messages, bool add_generation_prompt = true, const std::string& tools_json = "", bool enable_thinking_if_supported = false) const; + + virtual uint32_t get_vocab_size() const = 0; + virtual uint32_t get_unk_token() const = 0; + virtual uint32_t get_bos_token() const = 0; + virtual uint32_t get_eos_token() const = 0; + virtual bool has_chat_template() const { return has_chat_template_; } + std::string get_default_stop_sequence() const; + + virtual bool load_vocabulary_with_config(const std::string& vocab_file, const std::string& merges_file, const std::string& config_file) = 0; + + uint32_t get_image_token_id() const { return image_token_id_; } + uint32_t get_fake_token_id() const { return fake_token_id_; } + uint32_t get_global_img_token_id() const { return global_img_token_id_; } + +protected: + enum class ModelType { UNKNOWN, QWEN, QWEN3P5, GEMMA, GEMMA4, LFM2, BERT, WHISPER, PARAKEET, YOUTU, NEEDLE}; + ModelType model_type_ = ModelType::UNKNOWN; + enum class ModelVariant { DEFAULT, VLM, EXTRACT, RAG}; + ModelVariant model_variant_ = ModelVariant::DEFAULT; + bool has_chat_template_ = false; + std::string chat_template_; + + uint32_t image_token_id_ = 396; + uint32_t fake_token_id_ = 49189; + uint32_t global_img_token_id_ = 49152; + + + uint32_t vision_patch_size_ = 16; + uint32_t vision_pooling_kernel_size_ = 3; + uint32_t vision_default_output_length_ = 280; + uint32_t vision_image_size_ = 768; + TokenizerRuntimeConfig runtime_config_; + + void detect_model_type(const std::string& config_path); + void load_chat_template(const std::string& template_file); + std::string format_qwen_style(const std::vector& messages, bool add_generation_prompt, const std::string& tools_json, bool enable_thinking_if_supported = false) const; + std::string format_gemma_style(const std::vector& messages, bool add_generation_prompt, const std::string& tools_json) const; + std::string format_gemma4_style(const std::vector& messages, bool add_generation_prompt, const std::string& tools_json, bool enable_thinking_if_supported = false) const; + std::string format_lfm2_style(const std::vector& messages, bool add_generation_prompt, const std::string& tools_json) const; + std::string format_lfm2_vl_style(const std::vector& messages, bool add_generation_prompt, const std::string& tools_json) const; + std::string format_needle_style(const std::vector& messages, bool add_generation_prompt, const std::string& tools_json) const; + std::string format_youtu_style(const std::vector& messages, bool add_generation_prompt, const std::string& tools_json) const; +}; + +class BPETokenizer : public Tokenizer { +public: + BPETokenizer(); + ~BPETokenizer(); + + bool load_vocabulary_mmap(const std::string& vocab_file, const std::string& merges_file); + bool load_vocabulary_with_config(const std::string& vocab_file, const std::string& merges_file, const std::string& config_file) override; + + std::vector encode(const std::string& text) const override; + std::string decode(const std::vector& tokens) const override; + + uint32_t get_vocab_size() const override { return vocab_size_; } + uint32_t get_unk_token() const override { return unk_token_id_; } + uint32_t get_bos_token() const override { return bos_token_id_; } + uint32_t get_eos_token() const override { return eos_token_id_; } + +private: + std::unordered_map token_to_id_; + std::vector id_to_token_; + std::vector merge_rules_; + std::unordered_map merge_map_; + + uint32_t vocab_size_; + uint32_t unk_token_id_; + uint32_t bos_token_id_; + uint32_t eos_token_id_; + + void* vocab_mmap_ptr_; + size_t vocab_mmap_size_; + + void* merges_mmap_ptr_; + size_t merges_mmap_size_; + + std::vector apply_bpe(const std::vector& tokens) const; + std::pair find_best_merge_fast(const std::vector& tokens) const; + + std::string bytes_to_unicode(const std::string& text) const; + std::string unicode_to_bytes(const std::string& text) const; + std::vector byte_level_split(const std::string& text) const; + std::vector utf8_split(const std::string& text) const; + + void cleanup_mmap(); + +private: + mutable std::unordered_map byte_to_unicode_; + mutable std::unordered_map unicode_to_byte_; + void init_byte_mappings() const; + + std::unordered_map special_tokens_; + std::vector split_with_special_tokens(const std::string& text) const; + void load_special_tokens(const std::string& config_file); +}; + +class SPTokenizer : public Tokenizer { +public: + SPTokenizer(); + ~SPTokenizer(); + + bool load_vocabulary_with_config(const std::string& vocab_file, const std::string& merges_file, const std::string& config_file) override; + + std::vector encode(const std::string& text) const override; + std::string decode(const std::vector& tokens) const override; + + uint32_t get_vocab_size() const override { return vocab_size_; } + uint32_t get_unk_token() const override { return unk_token_id_; } + uint32_t get_bos_token() const override { return bos_token_id_; } + uint32_t get_eos_token() const override { return eos_token_id_; } + +private: + struct TrieNode { + std::unordered_map> children; + int32_t token_id = -1; + float score = 0.0f; + }; + + std::unique_ptr trie_root_; + std::unordered_map token_to_id_; + std::vector id_to_token_; + std::vector token_scores_; + + uint32_t vocab_size_; + uint32_t unk_token_id_; + uint32_t bos_token_id_; + uint32_t eos_token_id_; + uint32_t pad_token_id_; + + bool sp_bpe_mode_ = false; + bool sp_add_dummy_prefix_ = false; + bool sp_byte_fallback_ = false; + + void* vocab_mmap_ptr_; + size_t vocab_mmap_size_; + + void build_trie(); + std::vector> tokenize_with_trie(const std::string& text) const; + std::vector tokenize_with_bpe(const std::string& text) const; + std::string preprocess_text(const std::string& text) const; + std::string postprocess_text(const std::string& text) const; + std::vector split_by_unicode_spaces(const std::string& text) const; + + void cleanup_mmap(); + + std::unordered_map special_tokens_; + std::vector split_with_special_tokens(const std::string& text) const; + void load_special_tokens(const std::string& config_file); +}; + +class ConvCache { +public: + struct CircularView { + const void* ptr1; + size_t len1; + const void* ptr2; + size_t len2; + size_t total_len; + }; + + void init(size_t layers, size_t hidden_dim, size_t window_len, Precision model_precision); + CircularView get_window(size_t layer) const; + void update(CactusGraph* gb, size_t layer, const size_t latest_token); + void reset(); + + bool is_empty() const { return num_layers == 0; } + + size_t num_layers = 0; + size_t hidden_size = 0; + size_t window_size = 0; + Precision precision = Precision::FP32; + size_t element_size = 4; + +private: + struct LayerState { + std::vector data; + size_t head = 0; + size_t count = 0; + }; + + std::vector layer_states; +}; + +struct KVCache { + static constexpr size_t DEFAULT_WINDOW_SIZE = 1024; + static constexpr size_t DEFAULT_SINK_SIZE = 4; + + struct LayerCache { + std::vector keys; + std::vector values; + std::vector key_scales; + std::vector value_scales; + size_t head_dim = 0; + size_t kv_heads = 0; + }; + + std::vector layer_caches; + + size_t window_size = DEFAULT_WINDOW_SIZE; + size_t sink_size = DEFAULT_SINK_SIZE; + size_t current_seq_len = 0; + size_t total_seq_len = 0; + size_t max_seq_len = 2048; + size_t num_layers = 0; + Precision precision; + size_t element_size = 4; + + void set_window_size(size_t window, size_t sink = DEFAULT_SINK_SIZE); + size_t get_effective_seq_len() const { return current_seq_len; } + size_t get_total_seq_len() const { return total_seq_len; } + size_t get_layer_head_dim(size_t layer_idx) const { return layer_caches[layer_idx].head_dim; } + size_t get_layer_kv_heads(size_t layer_idx) const { return layer_caches[layer_idx].kv_heads; } + + void init(size_t num_layers, size_t max_seq, const std::vector& layer_dims, const std::vector& layer_kv_heads, Precision model_precision); + void reset(); + void update_from_graph(CactusGraph* gb, const std::vector& k_nodes, + const std::vector& v_nodes, size_t seq_len, + size_t num_layers); + + void update_from_npu(size_t layer_idx, const __fp16* k_data, const __fp16* v_data, + size_t num_tokens, size_t kv_heads, size_t head_dim); + + bool is_empty() const { return current_seq_len == 0; } + void* get_key_ptr(size_t layer); + void* get_value_ptr(size_t layer); + + struct CircularView { + const void* ptr1; + const void* ptr2; + size_t len1; + size_t len2; + size_t total_len; + }; + + CircularView get_key_view(size_t layer); + CircularView get_value_view(size_t layer); + + const int8_t* get_keys_int8(size_t layer) const; + const int8_t* get_values_int8(size_t layer) const; + const float* get_key_scales(size_t layer) const; + const float* get_value_scales(size_t layer) const; + + void remove_token_range(size_t start, size_t count); + void compact_to_windows(const std::vector& target_windows); +}; + +class ToolCallConstrainer { +public: + enum class State { + DONE, + + QWEN_START, + QWEN_EXPECT_OPEN_BRACE, + QWEN_EXPECT_NAME_KEY, + QWEN_EXPECT_NAME_COLON, + QWEN_EXPECT_NAME_VALUE, + QWEN_EXPECT_COMMA, + QWEN_EXPECT_ARGS_KEY, + QWEN_EXPECT_ARGS_COLON, + QWEN_IN_ARGUMENTS, + QWEN_EXPECT_CLOSE_BRACE, + QWEN_EXPECT_END, + + NEEDLE_START, + + LFM_START, + LFM_EXPECT_BRACKET, + LFM_IN_FUNC_NAME, + LFM_EXPECT_PAREN, + LFM_IN_ARGUMENTS, + LFM_EXPECT_BRACKET_CLOSE, + LFM_EXPECT_END, + + GEMMA_START, + GEMMA_EXPECT_CALL, + GEMMA_IN_FUNC_NAME, + GEMMA_EXPECT_BRACE, + GEMMA_IN_ARGUMENTS, + GEMMA_EXPECT_END + }; + + void init(Config::ModelType model_type, + const std::vector& tools, + Tokenizer* tokenizer); + + const std::unordered_map& get_bias() const { return current_bias_; } + + void update(uint32_t token_id, const std::string& decoded_text); + + void reset(); + + bool is_active() const { return active_; } + +private: + bool active_ = false; + State state_ = State::QWEN_START; + Config::ModelType model_type_ = Config::ModelType::QWEN; + Tokenizer* tokenizer_ = nullptr; + + bool is_gemma_family() const { return Config::is_gemma_family(model_type_); } + bool is_needle() const { return model_type_ == Config::ModelType::NEEDLE; } + + enum class NeedleJsonState { + FREE, + IN_NAME, + IN_ARG_KEY, + }; + + struct NeedleTrieNode { + std::unordered_map> children; + bool is_terminal = false; + }; + + std::vector tool_specs_; + std::vector function_names_; + std::string generated_text_; + int brace_depth_ = 0; + + std::string call_start_tag_; + std::string call_end_tag_; + + std::unordered_set qwen_tool_call_start_tokens_; + std::unordered_set qwen_tool_call_end_tokens_; + std::unordered_set open_brace_tokens_; + std::unordered_set close_brace_tokens_; + std::unordered_set colon_tokens_; + std::unordered_set comma_tokens_; + std::unordered_set name_key_tokens_; + std::unordered_set args_key_tokens_; + std::unordered_set quote_tokens_; + std::unordered_set backtick_tokens_; + std::unordered_set all_func_name_tokens_; + std::unordered_map> func_name_sequences_; + NeedleJsonState needle_json_state_ = NeedleJsonState::FREE; + std::string needle_buffer_; + std::string needle_constrained_buf_; + std::string needle_current_function_; + bool needle_in_arguments_ = false; + int needle_arguments_depth_ = 0; + int needle_nesting_depth_ = 0; + bool needle_in_string_value_ = false; + bool needle_between_pairs_ = false; + bool needle_prev_char_escape_ = false; + std::unique_ptr needle_name_trie_; + std::unordered_map> needle_param_tries_; + std::unordered_map> needle_required_params_; + std::unordered_set needle_seen_arg_keys_; + std::vector needle_token_strings_; + std::unordered_map> needle_token_index_; + std::unordered_set needle_arg_close_tokens_; + + std::unordered_set tool_start_tokens_; + std::unordered_set tool_end_tokens_; + std::unordered_set bracket_open_tokens_; + std::unordered_set bracket_close_tokens_; + std::unordered_set paren_open_tokens_; + std::unordered_set paren_close_tokens_; + std::unordered_set equals_tokens_; + + std::unordered_set gemma_call_start_tokens_; + std::unordered_set gemma_call_end_tokens_; + std::unordered_set gemma_response_start_tokens_; + std::unordered_set gemma_call_prefix_tokens_; + std::unordered_set escape_tokens_; + + std::unordered_map current_bias_; + + void compute_bias(); + void tokenize_grammar_elements(); + void add_tokens_for_string(const std::string& str, std::unordered_set& token_set); + void tokenize_function_names(bool quote_names); + void init_common_tokens(); + void init_needle_constraints(); + void reset_needle_constraints(); + void feed_needle_text(const std::string& text); + void feed_needle_char(char ch); + bool needle_at_arg_key_start() const; + bool needle_is_value_string_start() const; + void needle_insert_word(NeedleTrieNode* root, const std::string& word); + const NeedleTrieNode* needle_get_trie_node(const NeedleTrieNode* root, const std::string& prefix) const; + bool needle_check_token_valid(const std::string& token_text, const NeedleTrieNode* trie_node) const; + bool needle_has_unseen_completion(const NeedleTrieNode* node, std::string& partial) const; +}; + +class Model { +public: + struct DebugNode { + uint32_t layer_idx; + std::string name; + size_t node_id; + }; + + Model(); + explicit Model(const Config& config); + virtual ~Model(); + + const Config& get_config() const { return config_; } + Tokenizer* get_tokenizer() const { return tokenizer_.get(); } + const std::vector& get_debug_nodes() const; + + virtual bool init(const std::string& model_folder, size_t context_size, const std::string& system_prompt = "", bool do_warmup = true); + + virtual bool init(CactusGraph* external_graph, const std::string& model_folder, size_t context_size, + const std::string& system_prompt = "", bool do_warmup = true); + + virtual uint32_t decode(const std::vector& tokens, float temperature = -1.0f, float top_p = -1.0f, + size_t top_k = 0, const std::string& profile_file = "", float* out_entropy = nullptr, + float min_p = 0.15f, float repetition_penalty = 1.1f); + + virtual void prefill(const std::vector& tokens, size_t chunk_size = 256, const std::string& profile_file = ""); + + virtual void prefill_with_images(const std::vector& tokens, const std::vector& image_paths, + const std::string& profile_file = ""); + + virtual uint32_t decode_with_images(const std::vector& tokens, const std::vector& image_paths, + float temperature = -1.0f, float top_p = -1.0f, + size_t top_k = 0, const std::string& profile_file = "", float* out_entropy = nullptr, + float min_p = 0.15f, float repetition_penalty = 1.1f); + + virtual uint32_t decode_with_audio(const std::vector& tokens, const std::vector& audio_features, float temperature = 0.0f, float top_p = 0.0f, + size_t top_k = 0, const std::string& profile_file = "", float* out_entropy = nullptr, + float min_p = 0.15f, float repetition_penalty = 1.1f, + float* out_token_time_start = nullptr, float* out_token_time_end = nullptr); + + std::vector get_embeddings(const std::vector& tokens, bool pooled = true, bool normalize = false, const std::string& profile_file = ""); + + virtual std::vector get_image_embeddings(const std::string& image_path); + + virtual std::vector get_audio_embeddings(const std::vector& audio_features); + + virtual void reset_cache() { kv_cache_.reset(); token_history_.clear(); } + void record_sampled_token(uint32_t token) { + if (token_history_.size() >= MAX_TOKEN_HISTORY) { + token_history_.erase(token_history_.begin(), token_history_.begin() + (MAX_TOKEN_HISTORY / 2)); + } + token_history_.push_back(token); + } + + double score_tokens_window_logprob(const std::vector& tokens, size_t start, size_t end, size_t context, size_t* tokens_scored); + + + + void set_cache_window(size_t window_size, size_t sink_size = 4) { kv_cache_.set_window_size(window_size, sink_size); } + + bool load_npu_prefill(const std::string& model_path); + bool has_npu_prefill() const; + size_t get_prefill_chunk_size() const; + + virtual void remove_thinking_tokens(const std::vector>& ranges); + virtual void compact_kv_cache() {} + + void set_tool_constraints(const std::vector& tools); + void clear_tool_constraints(); + void update_tool_constraints(uint32_t token_id); + + void* graph_handle_; + + void set_vocab_bias(const std::unordered_map& bias) { + vocab_bias_ = bias; + } + + void clear_vocab_bias() { + vocab_bias_.clear(); + } + + bool has_vocab_bias() const { + return !vocab_bias_.empty(); + } + + const std::unordered_map& get_vocab_bias() const { + return vocab_bias_; + } + +protected: + size_t sample_token(CactusGraph* gb, size_t logits_node_id, float temperature, float top_p, size_t top_k, + float min_p, float repetition_penalty, + const std::unordered_map* extra_bias = nullptr) const; + + static void compute_entropy(CactusGraph* gb, size_t logits_node_id, float* out_entropy); + + virtual size_t forward(const std::vector& tokens, bool use_cache = false) = 0; + + virtual size_t forward(const std::vector& audio_features, const std::vector& tokens, bool use_cache = false); + + virtual void load_weights_to_graph(CactusGraph* gb) = 0; + + virtual size_t build_attention(CactusGraph* gb, size_t normalized_input, uint32_t layer_idx, + ComputeBackend backend, bool use_cache = false, size_t position_offset = 0) = 0; + + virtual size_t build_mlp(CactusGraph* gb, size_t normalized_h, uint32_t layer_idx, + ComputeBackend backend) const = 0; + virtual size_t build_transformer_block(CactusGraph* gb, size_t hidden, uint32_t layer_idx, + ComputeBackend backend, bool use_cache = false, size_t position_offset = 0) = 0; + void update_kv_cache(CactusGraph* gb, size_t seq_len); + virtual std::vector get_kv_layer_dims() const { + return std::vector(config_.num_layers, config_.attention_head_dim); + } + virtual std::vector get_kv_layer_heads() const { + return std::vector(config_.num_layers, config_.attention_kv_heads); + } + virtual void post_init() {} + virtual void post_execute_updates(CactusGraph*, size_t) {} + Config config_; + std::unique_ptr tokenizer_; + + bool initialized_; + float attention_scale_; + +protected: + KVCache kv_cache_; + std::vector cache_k_output_nodes_; + std::vector cache_v_output_nodes_; + + std::string embedding_file_path_; + size_t embedding_node_id_; + std::string model_folder_path_; + size_t output_weight_node_id_; + + mutable std::vector debug_nodes_; + + void capture_debug_node(uint32_t layer_idx, const std::string& name, size_t node_id) const; + void clear_debug_nodes(); + + bool init_internal(CactusGraph* gb, const std::string& model_folder, size_t context_size, + const std::string& system_prompt, bool do_warmup); + bool owns_graph_; + + std::unique_ptr npu_prefill_; + void prefill_npu(const std::vector& tokens); + virtual std::vector<__fp16> get_token_embeddings(const std::vector& tokens); + + static constexpr size_t MAX_TOKEN_HISTORY = 128; + ToolCallConstrainer tool_constrainer_; + std::vector token_history_; + +private: + std::unordered_map vocab_bias_; +}; + +std::unique_ptr create_model(const std::string& model_folder); + +class Siglip2Preprocessor { +public: + struct Config { + int patch_size = 16; + int downsample_factor = 2; + int min_tiles = 2; + int max_tiles = 10; + bool use_thumbnail = true; + int min_image_tokens = 64; + int max_image_tokens = 256; + int max_num_patches = 1024; + int tile_size = 512; + float max_pixels_tolerance = 2.0f; + bool do_resize = true; + bool do_rescale = true; + bool do_normalize = true; + bool do_convert_rgb = true; + bool do_image_splitting = true; + float rescale_factor = 1.0f / 255.0f; + float image_mean[3] = {0.5f, 0.5f, 0.5f}; + float image_std[3] = {0.5f, 0.5f, 0.5f}; + }; + + struct PreprocessedImage { + std::vector pixel_values; + std::vector pixel_attention_mask; + std::vector> spatial_shapes; + std::vector pixel_values_shape; + std::vector pixel_attention_mask_shape; + std::vector spatial_shapes_shape; + int num_patches_height; + int num_patches_width; + int actual_num_patches; + int num_tiles; + int patch_dim; + int max_patches_per_tile; + + int image_rows; + int image_cols; + int image_height; + int image_width; + int tokens_per_tile; + int thumbnail_tokens; + + ~PreprocessedImage(); + }; + + struct SpatialShapeResult { + std::vector> shapes; + int grid_rows; + int grid_cols; + }; + + explicit Siglip2Preprocessor(const Config& config); + Siglip2Preprocessor(); + ~Siglip2Preprocessor(); + + PreprocessedImage preprocess_from_file(const std::string& image_path); + PreprocessedImage preprocess_from_memory(const unsigned char* img_data, int width, int height, int channels); + SpatialShapeResult compute_spatial_shapes(int height, int width); + +private: + Config config_; + + std::pair compute_pixel_limits() const; + std::vector convert_to_rgb(const unsigned char* img_data, int width, int height, int channels); + std::pair smart_resize(int height, int width); + bool is_image_too_large(int height, int width); + std::pair get_grid_layout(int height, int width); + std::pair find_closest_aspect_ratio(float aspect_ratio, int width, int height); + std::vector resize_image(const unsigned char* img_data, int src_width, int src_height, + int dst_width, int dst_height, int channels); + std::vector normalize_image(const float* img_data, int width, int height, int channels); + std::vector> convert_image_to_patches( + const std::vector& image, int width, int height, int channels, int patch_size); + PreprocessedImage pad_patches(const std::vector>& tile_patches, + const std::vector>& spatial_shapes, + int patch_dim, + int max_patches_per_tile); + int round_by_factor(int number, int factor); +}; + +class AudioProcessor { +public: + struct SpectrogramConfig { + size_t n_fft = 400; + size_t hop_length = 160; + size_t frame_length = 400; + float power = 2.0f; + bool center = true; + const char* pad_mode = "reflect"; + bool onesided = true; + float dither = 0.0f; + float mel_floor = 1e-10f; + const char* log_mel = nullptr; + float reference = 1.0f; + float min_value = 1e-10f; + bool remove_dc_offset = false; + float preemphasis = 0.0f; + bool hann_periodic = true; + float window_a0 = 0.5f; + size_t fft_override = 0; + bool mel_floor_additive = false; + }; + + AudioProcessor(); + ~AudioProcessor(); + + void init_mel_filters(size_t num_frequency_bins, size_t num_mel_filters, + float min_freq, float max_freq, size_t sampling_rate, + const char* norm = "slaney", const char* mel_scale = "slaney"); + + std::vector compute_spectrogram( + const std::vector& waveform, + const SpectrogramConfig& config); + + static std::vector compute_irfft( + const std::vector& complex_input, + size_t n, + const char* norm = "backward"); + + const std::vector& get_mel_filters() const { return mel_filters_; } + + size_t get_num_mel_filters() const { return num_mel_filters_; } + size_t get_num_frequency_bins() const { return num_frequency_bins_; } + +private: + std::vector mel_filters_; + size_t num_frequency_bins_; + size_t num_mel_filters_; +}; + +namespace index { + constexpr uint32_t MAGIC = 0x43414354; + constexpr uint32_t VERSION = 1; + + struct Document { + int id; + std::vector embedding; + std::string content; + std::string metadata; + }; + + struct QueryResult { + int doc_id; + float score; + + QueryResult(int doc_id, float score) : doc_id(doc_id), score(score) {} + }; + + struct QueryOptions { + size_t top_k = 10; + float score_threshold = -1.0f; + }; + + class Index { + public: + Index(const std::string& index_path, const std::string& data_path, size_t embedding_dim); + ~Index(); + + Index(const Index&) = delete; + Index& operator=(const Index&) = delete; + Index(Index&&) = delete; + Index& operator=(Index&&) = delete; + + void add_documents(const std::vector& documents); + void delete_documents(const std::vector& doc_ids); + std::vector get_documents(const std::vector& doc_ids); + std::vector> query(const std::vector>& embeddings, const QueryOptions& options); + void compact(); + + private: + struct IndexHeader { + uint32_t magic; + uint32_t version; + uint32_t embedding_dim; + uint32_t num_documents; + }; + + struct IndexEntry { + int32_t doc_id; + uint64_t data_offset; + uint8_t flags; // bit 0: tombstone + + const __fp16* embedding() const { + return reinterpret_cast(this + 1); + } + + static size_t size(size_t embedding_dim) { + return sizeof(IndexEntry) + embedding_dim * sizeof(__fp16); + } + }; + + struct DataHeader { + uint32_t magic; + uint32_t version; + }; + + struct DataEntry { + uint16_t content_len; + uint16_t metadata_len; + + const char* content() const { + return reinterpret_cast(this + 1); + } + + const char* metadata() const { + return content() + content_len; + } + }; + + void parse_index_header(); + void parse_data_header(); + void build_doc_id_map(); + void validate_documents(const std::vector& documents); + void validate_doc_ids(const std::vector& doc_ids); + ssize_t write_full(int fd, const void* buf, size_t count); + + std::unordered_map doc_id_map_; + + std::string index_path_, data_path_; + size_t embedding_dim_; + size_t index_entry_size_; + uint32_t num_documents_; + + int index_fd_, data_fd_; + void *mapped_index_, *mapped_data_; + size_t index_file_size_, data_file_size_; + }; +} // namespace index + +} +} diff --git a/Frameworks/cactus-ios.xcframework/ios-arm64/cactus.framework/Headers/gemma_tools.h b/Frameworks/cactus-ios.xcframework/ios-arm64/cactus.framework/Headers/gemma_tools.h new file mode 100644 index 00000000..f0f9fe26 --- /dev/null +++ b/Frameworks/cactus-ios.xcframework/ios-arm64/cactus.framework/Headers/gemma_tools.h @@ -0,0 +1,576 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace gemma { + +inline std::string to_upper(const std::string& s) { + std::string result = s; + for (auto& c : result) c = std::toupper(c); + return result; +} + +inline std::string escape(const std::string& s) { + return "" + s + ""; +} + +inline void skip_whitespace(const std::string& json, size_t& pos) { + while (pos < json.length() && std::isspace(json[pos])) pos++; +} + +inline std::string extract_json_string(const std::string& json, size_t& pos) { + std::string value; + while (pos < json.length() && json[pos] != '"') { + if (json[pos] == '\\' && pos + 1 < json.length()) { + pos++; + if (json[pos] == 'n') value += '\n'; + else if (json[pos] == 't') value += '\t'; + else if (json[pos] == 'r') value += '\r'; + else if (json[pos] == '"') value += '"'; + else if (json[pos] == '\\') value += '\\'; + else value += json[pos]; + } else { + value += json[pos]; + } + pos++; + } + if (pos < json.length()) pos++; + return value; +} + +std::string format_argument(const std::string& json, size_t& pos, bool escape_keys); +std::string format_parameters(const std::string& properties_json, const std::string& /*required_json*/); + +inline std::string format_argument(const std::string& json, size_t& pos, bool escape_keys = true) { + skip_whitespace(json, pos); + if (pos >= json.length()) return ""; + + char c = json[pos]; + + if (c == '"') { + pos++; + std::string value = extract_json_string(json, pos); + return escape(value); + } else if (c == '{') { + std::string result = "{"; + pos++; + bool first = true; + + while (pos < json.length()) { + skip_whitespace(json, pos); + if (pos >= json.length() || json[pos] == '}') { pos++; break; } + if (json[pos] == ',') { pos++; continue; } + + if (json[pos] != '"') break; + pos++; + std::string key = extract_json_string(json, pos); + + skip_whitespace(json, pos); + if (pos < json.length() && json[pos] == ':') pos++; + + std::string value = format_argument(json, pos, escape_keys); + + if (!first) result += ","; + first = false; + if (escape_keys) { + result += escape(key) + ":" + value; + } else { + result += key + ":" + value; + } + } + result += "}"; + return result; + } else if (c == '[') { + std::string result = "["; + pos++; + bool first = true; + + while (pos < json.length()) { + skip_whitespace(json, pos); + if (pos >= json.length() || json[pos] == ']') { pos++; break; } + if (json[pos] == ',') { pos++; continue; } + + std::string value = format_argument(json, pos, escape_keys); + + if (!first) result += ","; + first = false; + result += value; + } + result += "]"; + return result; + } else if (json.compare(pos, 4, "true") == 0) { + pos += 4; + return "true"; + } else if (json.compare(pos, 5, "false") == 0) { + pos += 5; + return "false"; + } else if (json.compare(pos, 4, "null") == 0) { + pos += 4; + return "null"; + } else { + size_t start = pos; + while (pos < json.length() && (std::isdigit(json[pos]) || json[pos] == '.' || + json[pos] == '-' || json[pos] == '+' || json[pos] == 'e' || json[pos] == 'E')) { + pos++; + } + return json.substr(start, pos - start); + } +} + +inline std::map parse_json_object_raw(const std::string& json, size_t& pos) { + std::map result; + skip_whitespace(json, pos); + if (pos >= json.length() || json[pos] != '{') return result; + pos++; + + while (pos < json.length()) { + skip_whitespace(json, pos); + if (pos >= json.length() || json[pos] == '}') { pos++; break; } + if (json[pos] == ',') { pos++; continue; } + + if (json[pos] != '"') break; + pos++; + std::string key = extract_json_string(json, pos); + + skip_whitespace(json, pos); + if (pos < json.length() && json[pos] == ':') pos++; + skip_whitespace(json, pos); + + size_t value_start = pos; + if (json[pos] == '"') { + pos++; + while (pos < json.length() && json[pos] != '"') { + if (json[pos] == '\\') pos++; + pos++; + } + pos++; + } else if (json[pos] == '{') { + int depth = 1; + pos++; + while (pos < json.length() && depth > 0) { + if (json[pos] == '{') depth++; + else if (json[pos] == '}') depth--; + else if (json[pos] == '"') { + pos++; + while (pos < json.length() && json[pos] != '"') { + if (json[pos] == '\\') pos++; + pos++; + } + } + pos++; + } + } else if (json[pos] == '[') { + int depth = 1; + pos++; + while (pos < json.length() && depth > 0) { + if (json[pos] == '[') depth++; + else if (json[pos] == ']') depth--; + else if (json[pos] == '"') { + pos++; + while (pos < json.length() && json[pos] != '"') { + if (json[pos] == '\\') pos++; + pos++; + } + } + pos++; + } + } else { + while (pos < json.length() && json[pos] != ',' && json[pos] != '}') pos++; + } + result[key] = json.substr(value_start, pos - value_start); + } + return result; +} + +inline std::string get_json_string_value(const std::string& json, size_t pos) { + skip_whitespace(json, pos); + if (pos < json.length() && json[pos] == '"') { + pos++; + return extract_json_string(json, pos); + } + return ""; +} + +inline std::string format_parameters(const std::string& properties_json, const std::string& /*required_json*/) { + static const std::set standard_keys = {"description", "type", "properties", "required", "nullable"}; + + size_t pos = 0; + auto properties = parse_json_object_raw(properties_json, pos); + + std::string result; + bool first = true; + + for (const auto& [key, value_json] : properties) { + if (standard_keys.count(key)) continue; + + if (!first) result += ","; + first = false; + + size_t prop_pos = 0; + auto prop_obj = parse_json_object_raw(value_json, prop_pos); + + result += key + ":{"; + + if (prop_obj.count("description")) { + std::string desc = get_json_string_value(prop_obj["description"], 0); + result += "description:" + escape(desc); + } + + std::string type_val; + if (prop_obj.count("type")) { + type_val = get_json_string_value(prop_obj["type"], 0); + } + + if (to_upper(type_val) == "STRING") { + if (prop_obj.count("enum")) { + size_t enum_pos = 0; + std::string enum_formatted = format_argument(prop_obj["enum"], enum_pos, true); + result += ",enum:" + enum_formatted; + } + } else if (to_upper(type_val) == "OBJECT") { + if (prop_obj.count("properties")) { + std::string nested_required; + if (prop_obj.count("required")) { + nested_required = prop_obj["required"]; + } + result += ",properties:{" + format_parameters(prop_obj["properties"], nested_required) + "}"; + } + if (prop_obj.count("required")) { + std::string req_items; + size_t req_pos = 0; + skip_whitespace(prop_obj["required"], req_pos); + if (req_pos < prop_obj["required"].length() && prop_obj["required"][req_pos] == '[') { + req_pos++; + bool req_first = true; + while (req_pos < prop_obj["required"].length()) { + skip_whitespace(prop_obj["required"], req_pos); + if (prop_obj["required"][req_pos] == ']') break; + if (prop_obj["required"][req_pos] == ',') { req_pos++; continue; } + if (prop_obj["required"][req_pos] == '"') { + req_pos++; + std::string req_item = extract_json_string(prop_obj["required"], req_pos); + if (!req_first) req_items += ","; + req_first = false; + req_items += escape(req_item); + } + } + } + if (!req_items.empty()) { + result += ",required:[" + req_items + "]"; + } + } + } else if (to_upper(type_val) == "ARRAY") { + if (prop_obj.count("items")) { + result += ",items:{"; + size_t items_pos = 0; + auto items_obj = parse_json_object_raw(prop_obj["items"], items_pos); + bool items_first = true; + + for (const auto& [item_key, item_value] : items_obj) { + if (!items_first) result += ","; + items_first = false; + + if (item_key == "properties") { + std::string items_required; + if (items_obj.count("required")) { + items_required = items_obj["required"]; + } + result += "properties:{" + format_parameters(item_value, items_required) + "}"; + } else if (item_key == "required") { + result += "required:["; + size_t req_pos = 0; + skip_whitespace(item_value, req_pos); + if (req_pos < item_value.length() && item_value[req_pos] == '[') { + req_pos++; + bool req_first = true; + while (req_pos < item_value.length()) { + skip_whitespace(item_value, req_pos); + if (item_value[req_pos] == ']') break; + if (item_value[req_pos] == ',') { req_pos++; continue; } + if (item_value[req_pos] == '"') { + req_pos++; + std::string req_item = extract_json_string(item_value, req_pos); + if (!req_first) result += ","; + req_first = false; + result += escape(req_item); + } + } + } + result += "]"; + } else if (item_key == "type") { + std::string item_type = get_json_string_value(item_value, 0); + result += "type:" + escape(to_upper(item_type)); + } else { + size_t val_pos = 0; + result += item_key + ":" + format_argument(item_value, val_pos, true); + } + } + result += "}"; + } + } + + if (!type_val.empty()) { + result += ",type:" + escape(to_upper(type_val)); + } + + result += "}"; + } + + return result; +} + +inline std::string format_function_declaration(const std::string& name, + const std::string& description, + const std::string& params_json) { + std::string result = "declaration:" + name + "{"; + result += "description:" + escape(description); + + if (!params_json.empty()) { + result += ",parameters:{"; + + size_t pos = 0; + auto params = parse_json_object_raw(params_json, pos); + + if (params.count("properties")) { + std::string required_json; + if (params.count("required")) { + required_json = params["required"]; + } + result += "properties:{" + format_parameters(params["properties"], required_json) + "}"; + } + + if (params.count("required")) { + std::string req_items; + size_t req_pos = 0; + skip_whitespace(params["required"], req_pos); + if (req_pos < params["required"].length() && params["required"][req_pos] == '[') { + req_pos++; + bool first = true; + while (req_pos < params["required"].length()) { + skip_whitespace(params["required"], req_pos); + if (params["required"][req_pos] == ']') break; + if (params["required"][req_pos] == ',') { req_pos++; continue; } + if (params["required"][req_pos] == '"') { + req_pos++; + std::string item = extract_json_string(params["required"], req_pos); + if (!first) req_items += ","; + first = false; + req_items += escape(item); + } + } + } + if (!req_items.empty()) { + result += ",required:[" + req_items + "]"; + } + } + + if (params.count("type")) { + std::string type_val = get_json_string_value(params["type"], 0); + result += ",type:" + escape(to_upper(type_val)); + } + + result += "}"; + } + + result += "}"; + return result; +} + +template +inline std::string format_tools(const std::vector& tools, bool use_pipe_tags = false) { + if (tools.empty()) return ""; + + const char* decl_start = use_pipe_tags ? "<|tool>" : ""; + const char* decl_end = use_pipe_tags ? "" : ""; + + std::string result; + for (const auto& tool : tools) { + result += decl_start; + std::string params_json; + auto it = tool.parameters.find("schema"); + if (it != tool.parameters.end()) { + params_json = it->second; + } + + result += format_function_declaration(tool.name, tool.description, params_json); + result += decl_end; + } + return result; +} + + +inline size_t match_quote_tag(const std::string& s, size_t pos) { + if (s.compare(pos, 8, "") == 0) return 8; + if (s.compare(pos, 5, "<|\"|>") == 0) return 5; + return 0; +} + +inline size_t find_quote_tag(const std::string& s, size_t pos) { + size_t e = s.find("", pos); + size_t t = s.find("<|\"|>", pos); + if (e == std::string::npos) return t; + if (t == std::string::npos) return e; + return std::min(e, t); +} + +inline std::string unescape(const std::string& s) { + const std::string ESCAPE_TAG = ""; + std::string result = s; + size_t pos = 0; + while ((pos = result.find(ESCAPE_TAG, pos)) != std::string::npos) { + result.erase(pos, ESCAPE_TAG.length()); + } + return result; +} + +inline std::string args_to_json(const std::string& args_content) { + std::string result = "{"; + size_t pos = 0; + bool first = true; + + if (!args_content.empty() && args_content[0] == '{') pos = 1; + + while (pos < args_content.length()) { + while (pos < args_content.length() && std::isspace(args_content[pos])) pos++; + if (pos >= args_content.length() || args_content[pos] == '}') break; + if (args_content[pos] == ',') { pos++; continue; } + + size_t key_start = pos; + while (pos < args_content.length() && args_content[pos] != ':') pos++; + std::string key = args_content.substr(key_start, pos - key_start); + if (pos < args_content.length()) pos++; + + std::string value; + while (pos < args_content.length() && std::isspace(args_content[pos])) pos++; + + if (pos < args_content.length()) { + size_t qtag_len = match_quote_tag(args_content, pos); + if (qtag_len > 0) { + pos += qtag_len; + size_t val_end = find_quote_tag(args_content, pos); + if (val_end != std::string::npos) { + value = "\"" + args_content.substr(pos, val_end - pos) + "\""; + pos = val_end + match_quote_tag(args_content, val_end); + } + } else if (args_content[pos] == '{') { + int depth = 1; + size_t start = pos; + pos++; + while (pos < args_content.length() && depth > 0) { + if (args_content[pos] == '{') depth++; + else if (args_content[pos] == '}') depth--; + pos++; + } + value = args_to_json(args_content.substr(start, pos - start)); + } else if (args_content[pos] == '[') { + int depth = 1; + size_t start = pos; + pos++; + while (pos < args_content.length() && depth > 0) { + if (args_content[pos] == '[') depth++; + else if (args_content[pos] == ']') depth--; + pos++; + } + std::string arr_content = args_content.substr(start + 1, pos - start - 2); + value = "["; + size_t arr_pos = 0; + bool first_item = true; + while (arr_pos < arr_content.length()) { + while (arr_pos < arr_content.length() && (std::isspace(arr_content[arr_pos]) || arr_content[arr_pos] == ',')) arr_pos++; + if (arr_pos >= arr_content.length()) break; + + if (!first_item) value += ","; + first_item = false; + + size_t aq_len = match_quote_tag(arr_content, arr_pos); + if (aq_len > 0) { + arr_pos += aq_len; + size_t end = find_quote_tag(arr_content, arr_pos); + if (end != std::string::npos) { + value += "\"" + arr_content.substr(arr_pos, end - arr_pos) + "\""; + arr_pos = end + match_quote_tag(arr_content, end); + } + } else { + size_t end = arr_content.find_first_of(",]", arr_pos); + if (end == std::string::npos) end = arr_content.length(); + value += arr_content.substr(arr_pos, end - arr_pos); + arr_pos = end; + } + } + value += "]"; + } else { + size_t val_start = pos; + while (pos < args_content.length() && args_content[pos] != ',' && args_content[pos] != '}') { + pos++; + } + value = args_content.substr(val_start, pos - val_start); + while (!value.empty() && std::isspace(value.back())) value.pop_back(); + } + } + + if (!first) result += ","; + first = false; + result += "\"" + key + "\":" + value; + } + + result += "}"; + return result; +} + +inline void parse_function_calls(std::string& response, std::vector& function_calls) { + + const std::string CALL_START = (response.find("<|tool_call>") != std::string::npos) + ? "<|tool_call>" : ""; + const std::string CALL_END = (CALL_START == "<|tool_call>") + ? "" : ""; + size_t pos = 0; + + while ((pos = response.find(CALL_START, pos)) != std::string::npos) { + size_t content_start = pos + CALL_START.length(); + size_t call_end_pos = response.find(CALL_END, content_start); + + size_t content_end = (call_end_pos != std::string::npos) ? call_end_pos : response.length(); + std::string call_content = response.substr(content_start, content_end - content_start); + + if (call_content.compare(0, 5, "call:") == 0) { + size_t brace_pos = call_content.find('{'); + + if (brace_pos == std::string::npos) { + size_t sep_pos = call_content.find_first_of(", ", 5); + if (sep_pos != std::string::npos) { + std::string func_name = call_content.substr(5, sep_pos - 5); + size_t args_start = sep_pos + 1; + while (args_start < call_content.length() && + (call_content[args_start] == ' ' || call_content[args_start] == ',')) { + args_start++; + } + std::string args_content = "{" + call_content.substr(args_start); + if (args_content.back() != '}') args_content += "}"; + + std::string args_json = args_to_json(args_content); + std::string json_call = "{\"name\":\"" + func_name + "\",\"arguments\":" + args_json + "}"; + function_calls.push_back(json_call); + } + } else { + std::string func_name = call_content.substr(5, brace_pos - 5); + std::string args_content = call_content.substr(brace_pos); + if (args_content.back() != '}') args_content += "}"; + + std::string args_json = args_to_json(args_content); + std::string json_call = "{\"name\":\"" + func_name + "\",\"arguments\":" + args_json + "}"; + function_calls.push_back(json_call); + } + } + + size_t erase_end = (call_end_pos != std::string::npos) ? + call_end_pos + CALL_END.length() : response.length(); + response.erase(pos, erase_end - pos); + } +} + +} // namespace gemma \ No newline at end of file diff --git a/Frameworks/cactus-ios.xcframework/ios-arm64/cactus.framework/Headers/graph.h b/Frameworks/cactus-ios.xcframework/ios-arm64/cactus.framework/Headers/graph.h new file mode 100644 index 00000000..9ad6fe38 --- /dev/null +++ b/Frameworks/cactus-ios.xcframework/ios-arm64/cactus.framework/Headers/graph.h @@ -0,0 +1,775 @@ +#ifndef GRAPH_H +#define GRAPH_H + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace cactus { + +enum class LogLevel { + DEBUG = 0, + INFO = 1, + WARN = 2, + ERROR = 3, + NONE = 4 +}; + +class Logger { +public: + static Logger& instance() { + static Logger logger; + return logger; + } + + void set_level(LogLevel level) { min_level_ = level; } + LogLevel get_level() const { return min_level_; } + + void set_callback(std::function cb) { + std::lock_guard lock(mutex_); + callback_ = cb; + } + + void log(LogLevel level, const std::string& component, const std::string& message) { + if (level < min_level_) return; + + std::lock_guard lock(mutex_); + + if (callback_) { + callback_(level, component, message); + } else { + std::cerr << "[" << level_string(level) << "] [" << component << "] " << message << std::endl; + } + + if (level == LogLevel::ERROR) { + last_error_ = "[" + component + "] " + message; + } + } + + const std::string& last_error() const { return last_error_; } + void clear_error() { last_error_.clear(); } + +private: + Logger() : min_level_(LogLevel::WARN) {} + + static const char* level_string(LogLevel level) { + switch (level) { + case LogLevel::DEBUG: return "DEBUG"; + case LogLevel::INFO: return "INFO"; + case LogLevel::WARN: return "WARN"; + case LogLevel::ERROR: return "ERROR"; + default: return "?"; + } + } + + LogLevel min_level_; + std::mutex mutex_; + std::string last_error_; + std::function callback_; +}; + +} // namespace cactus + +#define CACTUS_LOG(level, component, msg) \ + do { \ + if (static_cast(level) >= static_cast(cactus::Logger::instance().get_level())) { \ + std::ostringstream _cactus_log_ss; \ + _cactus_log_ss << msg; \ + cactus::Logger::instance().log(level, component, _cactus_log_ss.str()); \ + } \ + } while(0) + +#define CACTUS_LOG_DEBUG(component, msg) CACTUS_LOG(cactus::LogLevel::DEBUG, component, msg) +#define CACTUS_LOG_INFO(component, msg) CACTUS_LOG(cactus::LogLevel::INFO, component, msg) +#define CACTUS_LOG_WARN(component, msg) CACTUS_LOG(cactus::LogLevel::WARN, component, msg) +#define CACTUS_LOG_ERROR(component, msg) CACTUS_LOG(cactus::LogLevel::ERROR, component, msg) + +namespace GraphFile { + class MappedFile; + struct SerializedGraph; +} + +enum class Precision { + INT8, + FP16, + FP32, + INT4 +}; + +enum class ComputeBackend { + CPU, + NPU +}; + +enum class Activation { + SILU, + GELU, + GELU_ERF, + RELU, + SIGMOID, + TANH +}; + +enum class OpType { + INPUT, PRECISION_CAST, + ADD, ADD_CLIPPED, SUBTRACT, MULTIPLY, DIVIDE, + ABS, POW, FLATTEN, VIEW, + MATMUL, TRANSPOSE, RESHAPE, SLICE, GATHER, EMBEDDING, + BILINEAR_INTERPOLATION, + SUM, MEAN, VARIANCE, MIN, MAX, + RMS_NORM, ROPE, ROPE_GPTJ, SOFTMAX, ATTENTION, ATTENTION_INT8_HYBRID, REL_POS_BIAS, CONV1D_CAUSAL, CONV1D_K3, CONV1D_K7S3, CONV1D, CONV1D_SAME_DEPTHWISE_K9, CONV1D_POINTWISE, CONV2D_K3S2P1, CONV2D_DEPTHWISE_K3S2P1, CONV2D_POINTWISE_1X1, GLU, BATCHNORM, + SCALAR_ADD, SCALAR_SUBTRACT, SCALAR_MULTIPLY, SCALAR_DIVIDE, SCALAR_EXP, SCALAR_SQRT, SCALAR_COS, SCALAR_SIN, SCALAR_LOG, + RELU, SILU, GELU, GELU_ERF, SIGMOID, TANH, + SAMPLE, CONCAT, CAT, + SCATTER_TOPK, + TOPK, LAYERNORM, GROUPNORM, + MOE_LAYER, + INDEX, + PERSISTENT, + QUANTIZE_ACTIVATIONS, + LSTM_CELL, + GATED_DELTANET_DECODE, + GATED_DELTANET_PREFILL, + STFT, + ALTUP_PREDICT, + ALTUP_CORRECT, + GAUSSIAN_TOPK, + MAXPOOL1D, + BILSTM_SEQUENCE, + LEAKY_RELU, + CONV2D_K3S1P1, + STATS_POOL, + WEIGHTED_STATS_POOL +}; + +struct PrecisionTraits { + static constexpr size_t size_of(Precision prec) { + switch (prec) { + case Precision::INT8: return 1; + case Precision::FP16: return 2; + case Precision::FP32: return 4; + case Precision::INT4: return 1; + } + return 1; + } + + static constexpr size_t packed_size_of(Precision prec, size_t count) { + switch (prec) { + case Precision::INT4: return (count + 1) / 2; + default: return count * size_of(prec); + } + } + + static size_t byte_offset_of(Precision prec, size_t element_offset) { + switch (prec) { + case Precision::INT4: + assert(element_offset % 32 == 0 && "INT4 byte offset must be group-aligned (multiple of 32)"); + return element_offset / 2; + default: return element_offset * size_of(prec); + } + } + + static constexpr bool is_integer(Precision prec) { + switch (prec) { + case Precision::INT8: return true; + case Precision::INT4: return true; + case Precision::FP16: return false; + case Precision::FP32: return false; + } + return true; + } + + static constexpr bool is_floating_point(Precision prec) { + switch (prec) { + case Precision::INT8: return false; + case Precision::INT4: return false; + case Precision::FP16: return true; + case Precision::FP32: return true; + } + return false; + } +}; + +namespace Quantization { + void int8_to_fp32(const int8_t* src, float* dst, size_t count, float scale = 1.0f); + void fp32_to_int8(const float* src, int8_t* dst, size_t count, float scale = 1.0f); + void fp16_to_fp32(const __fp16* src, float* dst, size_t count); + void fp32_to_fp16(const float* src, __fp16* dst, size_t count); + void int8_to_fp16(const int8_t* src, __fp16* dst, size_t count, float scale = 1.0f); + void fp16_to_int8(const __fp16* src, int8_t* dst, size_t count, float scale = 1.0f); +} + +struct TensorConfig { + Precision default_precision = Precision::INT8; + Precision compute_precision = Precision::INT8; + Precision output_precision = Precision::INT8; + bool auto_mixed_precision = false; + + static TensorConfig& global(); +}; + +struct BroadcastInfo { + std::vector output_shape; + bool needs_broadcasting; + + static BroadcastInfo compute(const std::vector& lhs, const std::vector& rhs); +}; + +class BufferPool; + +struct BufferDesc { + std::vector shape; + size_t total_size; + size_t byte_size; + std::unique_ptr data; + void* external_data; + char* pooled_data; + Precision precision; + + size_t group_size = 0; + size_t num_groups = 0; + void* scales_data = nullptr; + std::unique_ptr owned_scales; + + bool is_interleaved = false; + size_t original_N = 0; + + void* activation_scales_data = nullptr; + std::unique_ptr owned_activation_scales; + size_t num_rows_for_activation_scales = 0; + + BufferDesc(); + BufferDesc(const std::vector& s, Precision prec = Precision::INT8); + ~BufferDesc(); + + BufferDesc(BufferDesc&& other) noexcept; + BufferDesc& operator=(BufferDesc&& other) noexcept; + + BufferDesc(const BufferDesc&) = delete; + BufferDesc& operator=(const BufferDesc&) = delete; + + void* get_data(); + const void* get_data() const; + + template + T* data_as() { return static_cast(get_data()); } + + template + const T* data_as() const { return static_cast(get_data()); } + + const __fp16* scales_as_fp16() const { + return reinterpret_cast(scales_data); + } + + bool is_grouped_int8() const { + return precision == Precision::INT8 && group_size > 0; + } + + bool is_grouped_int4() const { + return precision == Precision::INT4 && group_size > 0; + } + + void set_grouped_scales(size_t gs, size_t ng, void* scales_ptr) { + group_size = gs; + num_groups = ng; + scales_data = scales_ptr; + } + + void set_interleaved(bool interleaved, size_t orig_n) { + is_interleaved = interleaved; + original_N = orig_n; + } + + bool has_activation_scales() const { + return activation_scales_data != nullptr && num_rows_for_activation_scales > 0; + } + const float* activation_scales_as_float() const { + return reinterpret_cast(activation_scales_data); + } + float* activation_scales_as_float() { + return reinterpret_cast(activation_scales_data); + } + void allocate_activation_scales(size_t num_rows) { + num_rows_for_activation_scales = num_rows; + owned_activation_scales = std::make_unique(num_rows * sizeof(float)); + activation_scales_data = owned_activation_scales.get(); + } + void set_activation_scales(void* scales_ptr, size_t num_rows) { + activation_scales_data = scales_ptr; + num_rows_for_activation_scales = num_rows; + } + + void allocate(); + void allocate_from_pool(BufferPool& pool); + void release_to_pool(BufferPool& pool); + void set_external(void* ptr); +}; + +struct OpParams { + float scalar = 0.0f; + float scale = 1.0f; + float theta = 10000.0f; + float epsilon = 1e-6f; + int axis = -1; + bool pretransposed_rhs = false; + size_t position_offset = 0; + size_t slice_start = 0; + size_t slice_length = 0; + size_t window_size = 0; + bool is_causal = true; + bool attention_mask_is_additive = false; + float logit_cap = 0.0f; + std::vector new_shape; + std::vector permutation; + Precision output_precision = Precision::INT8; + BroadcastInfo broadcast_info; + ComputeBackend backend = ComputeBackend::CPU; + + size_t dilation = 1; + size_t stride = 1; + float temperature = 1.0f; + float top_p = 1.0f; + float min_p = 0.15f; + float repetition_penalty = 1.1f; + size_t top_k = 0; + size_t random_seed = 0; + + size_t index_value = 0; + size_t num_classes = 0; + size_t num_groups = 0; + size_t dst_height = 0; + size_t dst_width = 0; + bool align_corners = true; + bool normalize_routing = false; + size_t num_experts = 0; + size_t num_experts_per_tok = 0; + bool moe_gated = true; + Activation activation = Activation::SILU; + + std::vector bias_values; + std::vector bias_indices; + + const int8_t* cached_keys_int8 = nullptr; + const int8_t* cached_values_int8 = nullptr; + const float* cached_k_scales = nullptr; + const float* cached_v_scales = nullptr; + size_t cache_seq_len = 0; + size_t num_kv_heads = 0; + size_t head_dim = 0; + size_t num_fft_bins = 0; + size_t chunk_size = 0; + size_t num_altup_inputs = 0; + size_t v_head_dim = 0; + size_t kernel_size = 0; +}; + +struct GraphNode { + size_t id; + OpType op_type; + std::vector input_ids; + BufferDesc output_buffer; + OpParams params; + + GraphNode(size_t node_id, OpType type); +}; + +using nodes_vector = std::vector>; +using node_index_map_t = std::unordered_map; + +inline const BufferDesc& get_input(const GraphNode& node, size_t idx, + const nodes_vector& nodes, + const node_index_map_t& node_index_map) { + return nodes[node_index_map.at(node.input_ids[idx])]->output_buffer; +} + +struct AxisDims { + size_t outer, axis_size, inner; + static AxisDims from_shape(const std::vector& shape, size_t axis) { + AxisDims d; + d.outer = 1; + for (size_t i = 0; i < axis; i++) d.outer *= shape[i]; + d.axis_size = shape[axis]; + d.inner = 1; + for (size_t i = axis + 1; i < shape.size(); i++) d.inner *= shape[i]; + return d; + } +}; + +template +void dispatch_binary_op(OpType op, const T* lhs, const T* rhs, T* output, size_t count); + +template +void dispatch_unary_op(OpType op, const T* input, T* output, size_t count, float param = 0.0f); + +void compute_node_optimized(GraphNode& node, const std::vector>& nodes, const std::unordered_map& node_index_map); +void compute_matmul_node(GraphNode& node, const std::vector>& nodes, const std::unordered_map& node_index_map); +void compute_transpose_node(GraphNode& node, const std::vector>& nodes, const std::unordered_map& node_index_map); +void compute_reduce_node(GraphNode& node, const std::vector>& nodes, const std::unordered_map& node_index_map); +void compute_fused_node(GraphNode& node, const std::vector>& nodes, const std::unordered_map& node_index_map); +void compute_reshape_node(GraphNode& node, const std::vector>& nodes, const std::unordered_map& node_index_map); +void compute_precision_cast_node(GraphNode& node, const std::vector>& nodes, const std::unordered_map& node_index_map); +void compute_sample_node(GraphNode& node, const std::vector>& nodes, const std::unordered_map& node_index_map); +void compute_scatter_topk_node(GraphNode& node, const std::vector>& nodes, const std::unordered_map& node_index_map); +void compute_topk_node(GraphNode& node, const std::vector>& nodes, const std::unordered_map& node_index_map); +void compute_layernorm_node(GraphNode& node, const std::vector>& nodes, const std::unordered_map& node_index_map); +void compute_groupnorm_node(GraphNode& node, const std::vector>& nodes, const std::unordered_map& node_index_map); +void compute_persistent_node(GraphNode& node, const std::vector>& nodes, const std::unordered_map& node_index_map); +void compute_index_node(GraphNode& node, const std::vector>& nodes, const std::unordered_map& node_index_map); +void compute_lstm_cell_node(GraphNode& node, const std::vector>& nodes, const std::unordered_map& node_index_map); +void compute_gated_deltanet_decode_node(GraphNode& node, const std::vector>& nodes, const std::unordered_map& node_index_map); +void compute_gated_deltanet_prefill_node(GraphNode& node, const std::vector>& nodes, const std::unordered_map& node_index_map); +void compute_altup_predict_node(GraphNode& node, const std::vector>& nodes, const std::unordered_map& node_index_map); +void compute_altup_correct_node(GraphNode& node, const std::vector>& nodes, const std::unordered_map& node_index_map); +void compute_maxpool1d_node(GraphNode& node, const std::vector>& nodes, const std::unordered_map& node_index_map); +void compute_bilstm_sequence_node(GraphNode& node, const std::vector>& nodes, const std::unordered_map& node_index_map); +void compute_conv2d_k3s1p1_node(GraphNode& node, const std::vector>& nodes, const std::unordered_map& node_index_map); +void compute_stats_pool_node(GraphNode& node, const std::vector>& nodes, const std::unordered_map& node_index_map); +void compute_weighted_stats_pool_node(GraphNode& node, const std::vector>& nodes, const std::unordered_map& node_index_map); + +void shrink_thread_local_buffers(); +class BufferPool { +public: + BufferPool() = default; + ~BufferPool() = default; + + BufferPool(const BufferPool&) = delete; + BufferPool& operator=(const BufferPool&) = delete; + BufferPool(BufferPool&&) noexcept = default; + BufferPool& operator=(BufferPool&&) noexcept = default; + + char* acquire(size_t byte_size); + void release(char* ptr, size_t byte_size); + void clear(); + + size_t active_bytes() const { return active_bytes_; } + size_t pool_bytes() const { return pool_bytes_; } + size_t peak_bytes() const { return peak_bytes_; } + +private: + std::unordered_map>> free_buffers_; + size_t active_bytes_ = 0; + size_t pool_bytes_ = 0; + size_t peak_bytes_ = 0; + + size_t round_up_size(size_t size) const; +}; + +namespace ValidationUtils { + void validate_tensor_dims(const std::vector& shape, size_t required_dims, const std::string& op_name); + void validate_precision(Precision actual, Precision required, const std::string& op_name); + void validate_input_count(size_t actual, size_t required, const std::string& op_name); +} + + +class CactusGraph { +public: + CactusGraph(); + ~CactusGraph() = default; + + CactusGraph(const CactusGraph&) = delete; + CactusGraph& operator=(const CactusGraph&) = delete; + CactusGraph(CactusGraph&&) noexcept = default; + CactusGraph& operator=(CactusGraph&&) noexcept = default; + + struct DebugNodeEntry { + uint32_t layer_idx; + std::string name; + size_t node_id; + }; + + void save(const std::string& path); + static CactusGraph load(const std::string& path); + + size_t input(const std::vector& shape, Precision precision = Precision::INT8); + size_t precision_cast(size_t input, Precision target_precision); + size_t quantize_activations(size_t input); + + size_t add(size_t input1, size_t input2); + size_t add_clipped(size_t input1, size_t input2); + size_t subtract(size_t input1, size_t input2); + size_t multiply(size_t input1, size_t input2); + size_t divide(size_t input1, size_t input2); + + size_t scalar_add(size_t input, float value); + size_t scalar_subtract(size_t input, float value); + size_t scalar_multiply(size_t input, float value); + size_t scalar_divide(size_t input, float value); + size_t scalar_exp(size_t input); + size_t scalar_sqrt(size_t input); + size_t scalar_cos(size_t input); + size_t scalar_sin(size_t input); + size_t scalar_log(size_t input); + + size_t relu(size_t input); + size_t silu(size_t input); + size_t gelu(size_t input); + size_t gelu_erf(size_t input); + size_t sigmoid(size_t input); + size_t tanh(size_t input); + size_t glu(size_t input, int axis = -1); + + size_t abs(size_t input); + size_t pow(size_t input, float exponent); + size_t view(size_t input, const std::vector& new_shape); + size_t flatten(size_t input, int start_dim = 0, int end_dim = -1); + + size_t matmul(size_t input1, size_t input2, bool pretransposed_rhs = false, ComputeBackend backend = ComputeBackend::CPU); + size_t transpose(size_t input, ComputeBackend backend = ComputeBackend::CPU); + size_t transposeN(size_t input, const std::vector& permutation, ComputeBackend backend = ComputeBackend::CPU); + size_t reshape(size_t input, const std::vector& new_shape); + size_t slice(size_t input, int axis, size_t start, size_t length); + size_t index(size_t input, size_t index_value, int dim); + + size_t sum(size_t input, int axis); + size_t mean(size_t input, int axis); + size_t variance(size_t input, int axis); + size_t min(size_t input, int axis); + size_t max(size_t input, int axis); + + size_t gather(size_t embeddings, size_t indices); + size_t mmap_embeddings(const std::string& filename); + size_t mmap_weights(const std::string& filename); + void set_grouped_scales(size_t node_id, size_t group_size, size_t num_groups, void* scales_ptr); + void set_interleaved(size_t node_id, bool interleaved, size_t original_N); + + void release_weight_pages(size_t node_id); + void prefetch_weight_pages(size_t node_id); + void release_all_weight_pages(); + size_t embedding(const std::string& filename, size_t indices); + size_t embedding(size_t embedding_tensor, size_t indices); + size_t bilinear_interpolation(size_t pos_embeds, size_t dst_height, size_t dst_width, bool align_corners = true); + + size_t layernorm(size_t input, size_t weight, size_t bias, float epsilon = 1e-5f); + size_t layernorm(size_t input, size_t weight, float epsilon = 1e-5f); // No bias version + size_t groupnorm(size_t input, size_t weight, size_t bias, size_t num_groups = 32, float epsilon = 1e-5f); + size_t batchnorm(size_t input, size_t weight, size_t bias, size_t running_mean, size_t running_var, int axis = 1, float epsilon = 1e-5f); + size_t topk(size_t input, size_t k); + size_t moe_layer(size_t hidden, + size_t routing_probs, + size_t topk_indices, + const std::vector& w1_weights, + const std::vector& w3_weights, + const std::vector& w2_weights, + size_t num_experts, + size_t num_experts_per_tok, + bool normalize_routing, + float epsilon, + float routed_scaling_factor, + Activation activation = Activation::SILU, + size_t per_expert_scale = 0); + size_t moe_layer(size_t hidden, + size_t routing_probs, + size_t topk_indices, + const std::vector& w1_weights, + const std::vector& w2_weights, + size_t num_experts, + size_t num_experts_per_tok, + bool normalize_routing, + float epsilon, + float routed_scaling_factor, + Activation activation); + size_t rms_norm(size_t input, size_t weight, float epsilon = 1e-5f); + size_t rope(size_t input, float theta, size_t position_offset = 0, ComputeBackend backend = ComputeBackend::CPU); + size_t rope_gptj(size_t input, float theta, size_t position_offset = 0, size_t rot_dim = 0, ComputeBackend backend = ComputeBackend::CPU); + size_t softmax(size_t input, int axis = -1); + size_t attention(size_t query, size_t key, size_t value, float scale, bool is_causal = true, ComputeBackend backend = ComputeBackend::CPU); + size_t attention(size_t query, size_t key, size_t value, float scale, size_t position_offset, ComputeBackend backend = ComputeBackend::CPU); + size_t attention(size_t query, size_t key, size_t value, float scale, size_t position_offset, size_t window_size, ComputeBackend backend = ComputeBackend::CPU); + size_t attention_masked(size_t query, size_t key, size_t value, size_t mask, float scale, + bool is_causal = true, ComputeBackend backend = ComputeBackend::CPU, + bool additive_mask = false, size_t position_offset = 0, size_t window_size = 0, + float logit_cap = 0.0f); + size_t rel_pos_bias(size_t query, size_t relative_key, float scale); + + size_t attention_int8_hybrid(size_t query, size_t key_new, size_t value_new, float scale, size_t position_offset, + const int8_t* cached_keys, const int8_t* cached_values, + const float* k_scales, const float* v_scales, + size_t cache_len, size_t num_kv_heads, size_t head_dim, + size_t window_size = 0, size_t v_head_dim = 0); + + size_t conv1d_causal(size_t input, size_t weight, size_t kernel_size, size_t dilation = 1); + size_t conv1d_k3(size_t input, size_t weight, size_t stride); + size_t conv1d_k7s3(size_t input, size_t weight, size_t bias); + size_t conv1d(size_t input, size_t weight, size_t stride); + size_t conv1d(size_t input, size_t weight, size_t bias, size_t stride); + size_t conv1d_same_depthwise_k9(size_t input, size_t weight); + size_t conv1d_same_depthwise_k9(size_t input, size_t weight, size_t bias); + size_t conv1d_pointwise(size_t input, size_t weight); + size_t conv1d_pointwise(size_t input, size_t weight, size_t bias); + size_t conv2d_k3s2p1(size_t input, size_t weight); + size_t conv2d_k3s2p1(size_t input, size_t weight, size_t bias); + size_t conv2d_depthwise_k3s2p1(size_t input, size_t weight); + size_t conv2d_depthwise_k3s2p1(size_t input, size_t weight, size_t bias); + size_t conv2d_pointwise_1x1(size_t input, size_t weight); + size_t conv2d_pointwise_1x1(size_t input, size_t weight, size_t bias); + + size_t lstm_cell(size_t input, size_t h_prev, size_t c_prev, size_t weight_ih, size_t weight_hh, size_t bias_ih, size_t bias_hh); + size_t gated_deltanet_decode(size_t query, size_t key, size_t value, size_t gate_log, size_t beta, + size_t initial_state, float scale = 0.0f); + size_t gated_deltanet_prefill(size_t query, size_t key, size_t value, size_t gate_log, size_t beta, + size_t initial_state, size_t chunk_size = 64, float scale = 0.0f); + size_t stft(size_t input, size_t weight, size_t stride, size_t num_fft_bins); + + size_t altup_predict(size_t coefs, const size_t* streams, size_t num_streams); + size_t altup_correct(size_t coefs, size_t innovation, const size_t* predictions, size_t num_predictions); + + size_t gaussian_topk(size_t input, float ppf); + + size_t maxpool1d(size_t input, size_t kernel_size, size_t stride); + size_t leaky_relu(size_t input, float negative_slope = 0.01f); + size_t bilstm_sequence(size_t input, size_t w_ih_fwd, size_t w_hh_fwd, size_t b_ih_fwd, size_t b_hh_fwd, + size_t w_ih_bwd, size_t w_hh_bwd, size_t b_ih_bwd, size_t b_hh_bwd); + size_t conv2d_k3s1p1(size_t input, size_t weight); + size_t conv2d_k3s1p1(size_t input, size_t weight, size_t bias); + size_t stats_pool(size_t input); + size_t weighted_stats_pool(size_t input, size_t weights); + + size_t sample(size_t logits, float temperature = 0.6f, float top_p = 0.95f, size_t top_k = 20, + const std::unordered_map& logit_bias = {}); + size_t sample_with_options(size_t logits, float temperature, float top_p, float min_p, float repetition_penalty, + size_t top_k, const std::unordered_map& logit_bias = {}); + + size_t concat(size_t input1, size_t input2, int axis = 0); + size_t cat(const std::vector& inputs, int axis); + size_t scatter_topk(size_t indices, size_t values, size_t num_classes); + + void set_input(size_t node_id, const void* data, Precision precision); + void set_external_input(size_t node_id, void* data, Precision precision); + void* get_output(size_t node_id); + + void execute(const std::string& profile_file = ""); + void hard_reset(); + void soft_reset(); + void soft_reset_keep_pool(); + void set_prefill_mode(bool enabled) { prefill_mode_ = enabled; } + + void register_debug_node(uint32_t layer_idx, const std::string& name, size_t node_id); + void capture_debug_node(uint32_t layer_idx, const std::string& name, size_t node_id); + const std::vector& get_debug_nodes() const; + void clear_debug_nodes(); + + size_t add_node(OpType op_type, const std::vector& inputs, const std::vector& output_shape, const OpParams& params = {}); + const BufferDesc& get_output_buffer(size_t node_id) const; + void allocate_buffers(); + size_t get_node_count() const; + + size_t persistent(size_t source_node); + bool is_populated(size_t persistent_node_id) const; + void invalidate_persistent(size_t persistent_node_id); + + std::vector> nodes_; + std::unordered_map node_index_map_; + +private: + static CactusGraph from_serialized(const GraphFile::SerializedGraph& serialized); + size_t next_node_id_; + std::vector> mapped_files_; + std::unordered_map weight_cache_; + std::unordered_map node_to_mapped_file_; + std::vector debug_nodes_; + BufferPool buffer_pool_; + bool prefill_mode_ = false; + + std::unordered_set persistent_node_ids_; + std::unordered_set populated_node_ids_; +}; + + +namespace GraphFile { + struct LoadedNode { + size_t node_id; + std::vector shape; + Precision precision; + size_t byte_size; + }; + + struct GraphHeader { + uint32_t magic; + uint32_t version; + uint32_t node_count; + uint32_t flags = 0; + }; + + struct NodeEntry { + uint32_t index; // serialized node index 0..n-1 + OpType op_type; + std::vector inputs; + std::vector output_shape; + Precision precision; + OpParams params; + }; + + struct SerializedGraph { + GraphHeader header; + std::vector nodes; + std::vector graph_inputs; // IDs of serialized inputs + std::vector graph_outputs; // IDs of serialized outputs + }; + + SerializedGraph load_graph(const std::string& filename); + void save_graph(const CactusGraph& graph, const std::string& filename); + + void save_node(CactusGraph& graph, size_t node_id, const std::string& filename); + + class MappedFile { + public: + MappedFile(const std::string& filename); + ~MappedFile(); + + MappedFile(const MappedFile&) = delete; + MappedFile& operator=(const MappedFile&) = delete; + MappedFile(MappedFile&& other) noexcept; + MappedFile& operator=(MappedFile&& other) noexcept; + + const std::vector& shape() const; + Precision precision() const; + size_t byte_size() const; + + size_t group_size() const { return group_size_; } + size_t num_groups() const { return num_groups_; } + const void* scales_data() const; + + bool is_interleaved() const { return is_interleaved_; } + size_t original_N() const { return original_N_; } + + void* data(); + const void* data() const; + + template + const T* typed_data() const; + + void release_pages(); + void prefetch_pages(); + + private: + int fd_; + void* mapped_data_; + size_t file_size_, data_offset_; + std::vector shape_; + Precision precision_; + size_t byte_size_; + size_t group_size_ = 0; + size_t num_groups_ = 0; + size_t scales_offset_ = 0; + size_t scales_bytes_ = 0; + uint32_t alignment_ = 32; + + bool is_interleaved_ = false; + size_t original_N_ = 0; + + void parse_header(); + void apply_madvise_hints(); + }; +} + +#endif diff --git a/Frameworks/cactus-ios.xcframework/ios-arm64/cactus.framework/Headers/kernel.h b/Frameworks/cactus-ios.xcframework/ios-arm64/cactus.framework/Headers/kernel.h new file mode 100644 index 00000000..2c0fdfc3 --- /dev/null +++ b/Frameworks/cactus-ios.xcframework/ios-arm64/cactus.framework/Headers/kernel.h @@ -0,0 +1,470 @@ +#ifndef KERNEL_H +#define KERNEL_H + +#include +#include + +enum class Precision; + +enum class ScalarOpType { + ADD, + SUBTRACT, + MULTIPLY, + DIVIDE, + ABS, + EXP, + POW, + SQRT, + COS, + SIN, + LOG +}; + +constexpr size_t KV_QUANT_GROUP_SIZE = 32; + +void cactus_add_f16(const __fp16* a, const __fp16* b, __fp16* output, size_t num_elements); +void cactus_add_f16_clipped(const __fp16* a, const __fp16* b, __fp16* output, size_t num_elements); +void cactus_subtract_f16(const __fp16* a, const __fp16* b, __fp16* output, size_t num_elements); +void cactus_multiply_f16(const __fp16* a, const __fp16* b, __fp16* output, size_t num_elements); +void cactus_add_scaled_f16(const __fp16* base, const __fp16* src, __fp16* output, size_t num_elements, float scale); +void cactus_divide_f16(const __fp16* a, const __fp16* b, __fp16* output, size_t num_elements); + +void cactus_add_broadcast_f16(const __fp16* a, const __fp16* b, __fp16* output, + const size_t* a_strides, const size_t* b_strides, + const size_t* output_shape, size_t ndim); +void cactus_subtract_broadcast_f16(const __fp16* a, const __fp16* b, __fp16* output, + const size_t* a_strides, const size_t* b_strides, + const size_t* output_shape, size_t ndim); +void cactus_multiply_broadcast_f16(const __fp16* a, const __fp16* b, __fp16* output, + const size_t* a_strides, const size_t* b_strides, + const size_t* output_shape, size_t ndim); +void cactus_divide_broadcast_f16(const __fp16* a, const __fp16* b, __fp16* output, + const size_t* a_strides, const size_t* b_strides, + const size_t* output_shape, size_t ndim); + +void cactus_scalar_op_f16(const __fp16* input, __fp16* output, size_t num_elements, float scalar_value, ScalarOpType op_type); + +void cactus_gemv_int8(const int8_t* A, float A_scale, + const int8_t* B, const __fp16* B_scales, + __fp16* C, size_t K, size_t N, size_t group_size); + +void cactus_gemm_int8(const int8_t* A, const float* A_scales, + const int8_t* B, const __fp16* B_scales, + __fp16* C, size_t M, size_t K, size_t N, size_t group_size); + +void cactus_matmul_int8(const int8_t* A, const float* A_scales, + const int8_t* B, const __fp16* B_scales, + __fp16* C, size_t M, size_t K, size_t N, size_t group_size); + +void cactus_gemv_int8_i8mm(const int8_t* A, float A_scale, + const int8_t* B, const __fp16* B_scales, + __fp16* C, size_t K, size_t N, size_t group_size); + +void cactus_gemm_int8_i8mm(const int8_t* A, const float* A_scales, + const int8_t* B, const __fp16* B_scales, + __fp16* C, size_t M, size_t K, size_t N, size_t group_size); + +void cactus_gemv_int4(const int8_t* A, float A_scale, + const int8_t* B_packed, const __fp16* B_scales, + __fp16* C, size_t K, size_t N, size_t group_size); + +void cactus_gemm_int4(const int8_t* A, const float* A_scales, + const int8_t* B_packed, const __fp16* B_scales, + __fp16* C, size_t M, size_t K, size_t N, size_t group_size); + +void cactus_matmul_int4(const int8_t* A, const float* A_scales, + const int8_t* B_packed, const __fp16* B_scales, + __fp16* C, size_t M, size_t K, size_t N, size_t group_size); + +void cactus_matmul_integer(Precision precision, + const int8_t* A, const float* A_scales, + const int8_t* B, const __fp16* B_scales, + __fp16* C, size_t M, size_t K, size_t N, size_t group_size); + +void cactus_matmul_f16(const __fp16* a, const __fp16* b_transposed, __fp16* c, + size_t M, size_t K, size_t N); + +void cactus_transpose_2d_f16(const __fp16* source, __fp16* destination, + size_t num_rows, size_t num_cols, size_t start_row, size_t end_row); +void cactus_transpose_f16(const __fp16* source, __fp16* destination, const size_t* shape, + const size_t* permutation, size_t ndim, size_t start_idx, size_t end_idx); + +double cactus_sum_all_f16(const __fp16* data, size_t num_elements); +void cactus_sum_axis_f16(const __fp16* input, __fp16* output, size_t outer_size, size_t axis_size, size_t inner_size); + +double cactus_mean_all_f16(const __fp16* data, size_t num_elements); +void cactus_mean_axis_f16(const __fp16* input, __fp16* output, size_t outer_size, size_t axis_size, size_t inner_size); + +double cactus_variance_all_f16(const __fp16* data, size_t num_elements); +void cactus_variance_axis_f16(const __fp16* input, __fp16* output, size_t outer_size, size_t axis_size, size_t inner_size); + +__fp16 cactus_min_all_f16(const __fp16* data, size_t num_elements); +void cactus_min_axis_f16(const __fp16* input, __fp16* output, size_t outer_size, size_t axis_size, size_t inner_size); + +__fp16 cactus_max_all_f16(const __fp16* data, size_t num_elements); +void cactus_max_axis_f16(const __fp16* input, __fp16* output, size_t outer_size, size_t axis_size, size_t inner_size); + +void cactus_rms_norm_f16(const __fp16* input, const __fp16* weight, __fp16* output, + size_t batch_size, size_t dims, float eps); + +void cactus_layer_norm_f16(const __fp16* input, const __fp16* weight, const __fp16* bias, + __fp16* output, size_t batch_size, size_t dims, float eps); + +void cactus_rope_f16(const __fp16* input, __fp16* output, size_t batch_size, size_t seq_len, + size_t num_heads, size_t head_dim, size_t start_pos, float theta); + +void cactus_gpt_j_rope_f16(const __fp16* input, __fp16* output, size_t batch_size, size_t seq_len, + size_t num_heads, size_t head_dim, size_t rot_dim, size_t start_pos, float theta); + +void cactus_softmax_f16(const __fp16* input, __fp16* output, size_t batch_size, + size_t seq_len, size_t vocab_size); + +void cactus_relu_f16(const __fp16* input, __fp16* output, size_t num_elements); + +void cactus_leaky_relu_f16(const __fp16* input, __fp16* output, size_t num_elements, float negative_slope); + +void cactus_silu_f16(const __fp16* input, __fp16* output, size_t num_elements); + +void cactus_gelu_f16(const __fp16* input, __fp16* output, size_t num_elements); + +void cactus_gelu_f16_erf(const __fp16* input, __fp16* output, size_t num_elements); + +void cactus_sigmoid_f16(const __fp16* input, __fp16* output, size_t num_elements); + +void cactus_tanh_f16(const __fp16* input, __fp16* output, size_t num_elements); + +void cactus_glu_f16( + const __fp16* input, + __fp16* output, + size_t outer_size, + size_t split_size, + size_t inner_size +); + +void cactus_glu_f32( + const float* input, + float* output, + size_t outer_size, + size_t split_size, + size_t inner_size +); + +void cactus_batchnorm_f16( + const __fp16* input, + const float* weight, + const float* bias, + const float* running_mean, + const float* running_var, + __fp16* output, + size_t outer_size, + size_t channels, + size_t inner_size, + float epsilon +); + +void cactus_batchnorm_f32( + const float* input, + const float* weight, + const float* bias, + const float* running_mean, + const float* running_var, + float* output, + size_t outer_size, + size_t channels, + size_t inner_size, + float epsilon +); + +void cactus_attention_f16(const __fp16* queries, const __fp16* keys, const __fp16* values, __fp16* output, + size_t batch_size, size_t seq_len, size_t kv_seq_len, size_t num_q_heads, size_t num_kv_heads, + size_t head_dim, float scale, const __fp16* mask, size_t position_offset = 0, size_t window_size = 0, + bool is_causal = true, bool mask_is_additive = false, bool mask_per_head = false, + size_t v_head_dim = 0, float logit_cap = 0.0f); + +void cactus_attention_hybrid_int8_fp16( + const __fp16* queries, + const int8_t* keys_cached, + const int8_t* values_cached, + const float* k_scales, + const float* v_scales, + const __fp16* keys_new, + const __fp16* values_new, + __fp16* output, + size_t batch_size, size_t seq_len, size_t cache_len, size_t new_len, + size_t num_q_heads, size_t num_kv_heads, size_t head_dim, + float scale, size_t position_offset = 0, bool is_causal = true, size_t window_size = 0, + size_t group_size = KV_QUANT_GROUP_SIZE, size_t v_head_dim = 0); + +void cactus_gated_deltanet_decode_f16( + const __fp16* q_data, + const __fp16* k_data, + const __fp16* v_data, + const __fp16* g_data, + const __fp16* b_data, + const __fp16* s_data, + __fp16* out, + size_t B, + size_t Hq, + size_t Hv, + size_t K, + size_t V, + float scale); + +void cactus_gated_deltanet_prefill_f16( + const __fp16* q_data, + const __fp16* k_data, + const __fp16* v_data, + const __fp16* g_data, + const __fp16* b_data, + const __fp16* s_data, + __fp16* out, + size_t B, + size_t T, + size_t Hq, + size_t Hv, + size_t K, + size_t V, + size_t requested_chunk_size, + float scale); + +void cactus_conv1d_causal_depthwise_f16( + const __fp16* input, + const __fp16* weight, + __fp16* output, + size_t N, + size_t L, + size_t C, + size_t K, + size_t dilation); + +void cactus_conv1d_f16_k3( + const __fp16* input, + const __fp16* weight, + __fp16* output, + size_t N, + size_t L, + size_t C_in, + size_t C_out, + size_t stride +); + +void cactus_conv1d_f16( + const __fp16* input, + const __fp16* weight, + const __fp16* bias, + __fp16* output, + size_t N, + size_t L, + size_t C_in, + size_t C_out, + size_t K, + size_t stride +); + +void cactus_stft_f16( + const __fp16* input, + const __fp16* weight, + __fp16* output, + size_t N, size_t L, + size_t C_in, size_t C_out, + size_t K, size_t stride, + size_t num_fft_bins +); + +void cactus_conv1d_f16_k7s3_oc8( + const __fp16* input, + const __fp16* Wpack, + const __fp16* bias, + __fp16* output, + size_t N, + size_t L, + size_t C_in, + size_t C_out +); + +void cactus_conv1d_same_depthwise_f16_k9( + const __fp16* input, + const __fp16* weight, + const __fp16* bias, + __fp16* output, + size_t N, + size_t L, + size_t C +); + +void cactus_conv2d_f16_k3s1p1_nchw( + const __fp16* input, + const __fp16* weight, + const __fp16* bias, + __fp16* output, + size_t N, + size_t C_in, + size_t H, + size_t W, + size_t C_out +); + +void cactus_conv2d_f16_k3s2p1_nchw( + const __fp16* input, + const __fp16* weight, + const __fp16* bias, + __fp16* output, + size_t N, + size_t C_in, + size_t H, + size_t W, + size_t C_out +); + +void cactus_conv2d_depthwise_f16_k3s2p1_nchw( + const __fp16* input, + const __fp16* weight, + const __fp16* bias, + __fp16* output, + size_t N, + size_t C, + size_t H, + size_t W +); + +void cactus_conv2d_pointwise_f16_1x1_nchw_gemm( + const __fp16* input, + const __fp16* weight, + const __fp16* bias, + __fp16* output, + size_t N, + size_t C_in, + size_t H, + size_t W, + size_t C_out +); + +void cactus_conv1d_pointwise_f16_gemm( + const __fp16* input, + const __fp16* weight, + const __fp16* bias, + __fp16* output, + size_t N, + size_t L, + size_t C_in, + size_t C_out +); + +void cactus_bilinear_interpolation_f16(const __fp16* input, __fp16* output, size_t src_height, size_t src_width, size_t embed_dim, + size_t dst_height, size_t dst_width, bool align_corners = true); + +void cactus_sample_f32(const float* logits, uint32_t* output, size_t vocab_size, + float temperature, float top_p, size_t top_k, size_t random_seed, + const float* bias_values = nullptr, const uint32_t* bias_indices = nullptr, + size_t bias_count = 0); +void cactus_sample_f16(const __fp16* logits, uint32_t* output, size_t vocab_size, + float temperature, float top_p, size_t top_k, size_t random_seed, + const float* bias_values = nullptr, const uint32_t* bias_indices = nullptr, + size_t bias_count = 0); + +void cactus_sample_f32_ex(const float* logits, uint32_t* output, size_t vocab_size, + float temperature, float top_p, float min_p, float repetition_penalty, + size_t top_k, size_t random_seed, + const float* bias_values = nullptr, const uint32_t* bias_indices = nullptr, + size_t bias_count = 0); +void cactus_sample_f16_ex(const __fp16* logits, uint32_t* output, size_t vocab_size, + float temperature, float top_p, float min_p, float repetition_penalty, + size_t top_k, size_t random_seed, + const float* bias_values = nullptr, const uint32_t* bias_indices = nullptr, + size_t bias_count = 0); + +void cactus_concat_f16(const __fp16* input1, const __fp16* input2, __fp16* output, + const size_t* shape1, const size_t* shape2, const size_t* output_shape, + size_t ndims, int axis); +void cactus_cat_f16(const __fp16** inputs, __fp16* output, const size_t** input_shapes, + const size_t* output_shape, size_t num_inputs, size_t rank, int axis); + +void cactus_int8_to_fp32(const int8_t* src, float* dst, size_t count, float scale = 1.0f); +void cactus_fp32_to_int8(const float* src, int8_t* dst, size_t count, float scale = 1.0f); +void cactus_fp16_to_fp32(const __fp16* src, float* dst, size_t count); +void cactus_fp32_to_fp16(const float* src, __fp16* dst, size_t count); +void cactus_int8_to_fp16(const int8_t* src, __fp16* dst, size_t count, float scale = 1.0f); +void cactus_fp16_to_int8(const __fp16* src, int8_t* dst, size_t count, float scale = 1.0f); +float cactus_fp16_max_abs(const __fp16* src, size_t count); + +void cactus_quantize_kv_fp16_to_int8( + const __fp16* src, + int8_t* dst, + float* scales, + size_t seq_len, size_t kv_heads, size_t head_dim, + size_t group_size = KV_QUANT_GROUP_SIZE); + +inline size_t kv_scales_count(size_t seq_len, size_t kv_heads, size_t head_dim, size_t group_size = KV_QUANT_GROUP_SIZE) { + size_t num_groups = (head_dim + group_size - 1) / group_size; + return seq_len * kv_heads * num_groups; +} + +void cactus_unpack_int4_to_int8(const uint8_t* packed, int8_t* unpacked, size_t unpacked_count); + +void cactus_gaussian_topk_f16( + const __fp16* input, + __fp16* output, + size_t rows, + size_t cols, + float ppf); + +void cactus_altup_predict_f16( + const __fp16* coefs, + const __fp16* const* streams, + __fp16* output, + size_t n, + size_t seq_len, + size_t hidden_dim); + +void cactus_altup_correct_f16( + const __fp16* coefs, + const __fp16* innovation, + const __fp16* const* predictions, + __fp16* output, + size_t n, + size_t seq_len, + size_t hidden_dim); + +void cactus_lstm_cell_f16( + const __fp16* x_input, + const __fp16* h_prev, + const __fp16* c_prev, + const __fp16* weight_ih, + const __fp16* weight_hh, + const __fp16* bias_ih, + const __fp16* bias_hh, + __fp16* h_new, + __fp16* c_new, + size_t batch_size, + size_t input_size, + size_t hidden_size +); + +void cactus_bilstm_sequence_f16( + const __fp16* input, + const __fp16* weight_ih_fwd, + const __fp16* weight_hh_fwd, + const __fp16* bias_ih_fwd, + const __fp16* bias_hh_fwd, + const __fp16* weight_ih_bwd, + const __fp16* weight_hh_bwd, + const __fp16* bias_ih_bwd, + const __fp16* bias_hh_bwd, + __fp16* output, + size_t batch_size, + size_t seq_len, + size_t input_size, + size_t hidden_size +); + +void cactus_maxpool1d_f16( + const __fp16* input, + __fp16* output, + size_t batch_size, + size_t channels, + size_t input_length, + size_t kernel_size, + size_t stride +); + +#endif diff --git a/Frameworks/cactus-ios.xcframework/ios-arm64/cactus.framework/Headers/kernel_utils.h b/Frameworks/cactus-ios.xcframework/ios-arm64/cactus.framework/Headers/kernel_utils.h new file mode 100644 index 00000000..568e3f3c --- /dev/null +++ b/Frameworks/cactus-ios.xcframework/ios-arm64/cactus.framework/Headers/kernel_utils.h @@ -0,0 +1,710 @@ +#ifndef KERNEL_UTILS_H +#define KERNEL_UTILS_H + +#include +#if defined(__APPLE__) +#include +#include +#endif +#if defined(__ANDROID__) +#include +#include +#include +#include +#endif +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +constexpr size_t NEON_VECTOR_SIZE = 16; +constexpr size_t STREAMING_STORE_THRESHOLD = 32768; + +inline void stream_store_f16x8(__fp16* dst, float16x8_t val) { +#if defined(__aarch64__) + float16x4_t lo = vget_low_f16(val); + float16x4_t hi = vget_high_f16(val); + __asm__ __volatile__( + "stnp %d0, %d1, [%2]" + : + : "w"(lo), "w"(hi), "r"(dst) + : "memory" + ); +#else + vst1q_f16(dst, val); +#endif +} + +inline bool cpu_has_i8mm() { +#if defined(__aarch64__) + static std::once_flag once; + static bool has = false; + + std::call_once(once, []() { +#if defined(__APPLE__) + int ret = 0; + size_t size = sizeof(ret); + if (sysctlbyname("hw.optional.arm.FEAT_I8MM", &ret, &size, nullptr, 0) == 0) { + has = (ret == 1); + } +#elif defined(__ANDROID__) + unsigned long hwcap2 = getauxval(AT_HWCAP2); + #ifndef HWCAP2_I8MM + #define HWCAP2_I8MM (1 << 13) + #endif + has = (hwcap2 & HWCAP2_I8MM) != 0; +#endif + }); + + return has; +#else + return false; +#endif +} + +inline bool cpu_has_sme2() { +#if defined(__aarch64__) + static std::once_flag once; + static bool has = false; + + std::call_once(once, []() { + +#if defined(__APPLE__) + int ret = 0; + size_t size = sizeof(ret); + if (sysctlbyname("hw.optional.arm.FEAT_SME2", &ret, &size, nullptr, 0) == 0) { + has = ret == 1; + } + +#elif defined(__ANDROID__) + unsigned long hwcap2 = getauxval(AT_HWCAP2); +#ifdef HWCAP2_SME2 + has = (hwcap2 & HWCAP2_SME2) != 0; +#endif + +#endif + }); + + return has; +#else + return false; +#endif +} + +inline float32x4_t fast_exp_f32x4(float32x4_t x) { + const float32x4_t log2e = vdupq_n_f32(1.4426950408889634f); + const float32x4_t ln2 = vdupq_n_f32(0.6931471805599453f); + + const float32x4_t c0 = vdupq_n_f32(1.0f); + const float32x4_t c1 = vdupq_n_f32(0.6931471805599453f); + const float32x4_t c2 = vdupq_n_f32(0.2402265069591007f); + const float32x4_t c3 = vdupq_n_f32(0.05550410866482158f); + const float32x4_t c4 = vdupq_n_f32(0.009618129842071803f); + + x = vmaxq_f32(x, vdupq_n_f32(-87.0f)); + x = vminq_f32(x, vdupq_n_f32(87.0f)); + + float32x4_t z = vmulq_f32(x, log2e); + + int32x4_t zi = vcvtq_s32_f32(z); + float32x4_t zf = vsubq_f32(z, vcvtq_f32_s32(zi)); + + uint32x4_t neg_mask = vcltq_f32(zf, vdupq_n_f32(0.0f)); + zi = vsubq_s32(zi, vandq_s32(vreinterpretq_s32_u32(neg_mask), vdupq_n_s32(1))); + zf = vaddq_f32(zf, vreinterpretq_f32_u32(vandq_u32(neg_mask, vreinterpretq_u32_f32(vdupq_n_f32(1.0f))))); + + float32x4_t zf_ln2 = vmulq_f32(zf, ln2); + float32x4_t p = c4; + p = vfmaq_f32(c3, p, zf_ln2); + p = vfmaq_f32(c2, p, zf_ln2); + p = vfmaq_f32(c1, p, zf_ln2); + p = vfmaq_f32(c0, p, zf_ln2); + + int32x4_t exp_bits = vshlq_n_s32(vaddq_s32(zi, vdupq_n_s32(127)), 23); + float32x4_t scale = vreinterpretq_f32_s32(exp_bits); + + return vmulq_f32(p, scale); +} + +// Cephes-style 13/6 rational tanh approximation (same coefficients as Eigen). +// Constants are stored as static splatted arrays so the compiler emits a single +// pc-relative `ldr q` per load. +alignas(16) inline constexpr float kFastTanhAlpha[7][4] = { + { 4.89352455891786e-03f, 4.89352455891786e-03f, 4.89352455891786e-03f, 4.89352455891786e-03f }, + { 6.37261928875436e-04f, 6.37261928875436e-04f, 6.37261928875436e-04f, 6.37261928875436e-04f }, + { 1.48572235717979e-05f, 1.48572235717979e-05f, 1.48572235717979e-05f, 1.48572235717979e-05f }, + { 5.12229709037114e-08f, 5.12229709037114e-08f, 5.12229709037114e-08f, 5.12229709037114e-08f }, + {-8.60467152213735e-11f,-8.60467152213735e-11f,-8.60467152213735e-11f,-8.60467152213735e-11f }, + { 2.00018790482477e-13f, 2.00018790482477e-13f, 2.00018790482477e-13f, 2.00018790482477e-13f }, + {-2.76076847742355e-16f,-2.76076847742355e-16f,-2.76076847742355e-16f,-2.76076847742355e-16f }, +}; +alignas(16) inline constexpr float kFastTanhBeta[4][4] = { + { 4.89352518554385e-03f, 4.89352518554385e-03f, 4.89352518554385e-03f, 4.89352518554385e-03f }, + { 2.26843463243900e-03f, 2.26843463243900e-03f, 2.26843463243900e-03f, 2.26843463243900e-03f }, + { 1.18534705686654e-04f, 1.18534705686654e-04f, 1.18534705686654e-04f, 1.18534705686654e-04f }, + { 1.19825839466702e-06f, 1.19825839466702e-06f, 1.19825839466702e-06f, 1.19825839466702e-06f }, +}; +alignas(16) inline constexpr float kFastTanhClampHi[4] = { 9.0f, 9.0f, 9.0f, 9.0f }; +alignas(16) inline constexpr float kFastTanhClampLo[4] = {-9.0f,-9.0f,-9.0f,-9.0f }; + +inline float32x4_t fast_tanh_f32x4(float32x4_t x) { + x = vmaxq_f32(vld1q_f32(kFastTanhClampLo), vminq_f32(vld1q_f32(kFastTanhClampHi), x)); + float32x4_t x2 = vmulq_f32(x, x); + float32x4_t p = vfmaq_f32(vld1q_f32(kFastTanhAlpha[5]), vld1q_f32(kFastTanhAlpha[6]), x2); + p = vfmaq_f32(vld1q_f32(kFastTanhAlpha[4]), p, x2); + p = vfmaq_f32(vld1q_f32(kFastTanhAlpha[3]), p, x2); + p = vfmaq_f32(vld1q_f32(kFastTanhAlpha[2]), p, x2); + p = vfmaq_f32(vld1q_f32(kFastTanhAlpha[1]), p, x2); + p = vfmaq_f32(vld1q_f32(kFastTanhAlpha[0]), p, x2); + p = vmulq_f32(p, x); + float32x4_t q = vfmaq_f32(vld1q_f32(kFastTanhBeta[2]), vld1q_f32(kFastTanhBeta[3]), x2); + q = vfmaq_f32(vld1q_f32(kFastTanhBeta[1]), q, x2); + q = vfmaq_f32(vld1q_f32(kFastTanhBeta[0]), q, x2); + return vdivq_f32(p, q); +} + +constexpr size_t SIMD_F16_WIDTH = 8; + +inline size_t simd_align(size_t count, size_t width = SIMD_F16_WIDTH) { + return (count / width) * width; +} + +inline void f16x8_split_f32(float16x8_t v, float32x4_t& lo, float32x4_t& hi) { + lo = vcvt_f32_f16(vget_low_f16(v)); + hi = vcvt_f32_f16(vget_high_f16(v)); +} + +inline float16x8_t f32_merge_f16(float32x4_t lo, float32x4_t hi) { + return vcombine_f16(vcvt_f16_f32(lo), vcvt_f16_f32(hi)); +} + +inline float32x4_t fast_sigmoid_f32x4(float32x4_t x) { + const float32x4_t one = vdupq_n_f32(1.0f); + return vdivq_f32(one, vaddq_f32(one, fast_exp_f32x4(vnegq_f32(x)))); +} + +template +inline float16x8_t apply_f32_op_on_f16x8(float16x8_t v, F32x4Op op) { + float32x4_t lo, hi; + f16x8_split_f32(v, lo, hi); + return f32_merge_f16(op(lo), op(hi)); +} + +inline void unpack_int4_as_int8x16x2(const uint8_t* ptr, int8x16_t& high_decoded, int8x16_t& low_decoded) { + int8x16_t packed = vreinterpretq_s8_u8(vld1q_u8(ptr)); + high_decoded = vshrq_n_s8(packed, 4); + low_decoded = vshrq_n_s8(vshlq_n_s8(packed, 4), 4); +} + +namespace CactusThreading { + +#if defined(__ANDROID__) + struct CoreTopology { + std::vector performance_cores; + std::vector all_cores; + + static CoreTopology& get() { + static CoreTopology topo = detect(); + return topo; + } + + private: + static int read_sysfs_int(const char* path) { + std::ifstream f(path); + if (!f.is_open()) return -1; + int val = -1; + f >> val; + return val; + } + + static CoreTopology detect() { + CoreTopology topo; + constexpr int MAX_CPUS = 16; + std::vector> core_caps; + + for (int i = 0; i < MAX_CPUS; ++i) { + char path[128]; + + snprintf(path, sizeof(path), + "/sys/devices/system/cpu/cpu%d/cpu_capacity", i); + int cap = read_sysfs_int(path); + if (cap > 0) { + core_caps.push_back({i, cap}); + topo.all_cores.push_back(i); + continue; + } + + snprintf(path, sizeof(path), + "/sys/devices/system/cpu/cpu%d/cpufreq/cpuinfo_max_freq", i); + int freq = read_sysfs_int(path); + if (freq > 0) { + core_caps.push_back({i, freq}); + topo.all_cores.push_back(i); + } + } + + if (core_caps.empty()) return topo; + + int max_cap = 0; + for (auto& [id, cap] : core_caps) { + max_cap = std::max(max_cap, cap); + } + + int threshold = static_cast(max_cap * 0.70); + for (auto& [id, cap] : core_caps) { + if (cap >= threshold) { + topo.performance_cores.push_back(id); + } + } + + return topo; + } + }; + + inline bool pin_current_thread_to_cores(const std::vector& cores) { + if (cores.empty()) return false; + cpu_set_t mask; + CPU_ZERO(&mask); + for (int core : cores) { + CPU_SET(core, &mask); + } + return sched_setaffinity(0, sizeof(mask), &mask) == 0; + } +#endif + + class ThreadPool { + private: + static constexpr size_t MAX_WORKERS = 16; + + std::vector workers; + std::deque> tasks; + + std::mutex mutex; + std::condition_variable work_available; + std::condition_variable work_done; + + bool stop{false}; + std::atomic pending_tasks{0}; + size_t num_workers_; + + void worker_thread() { + while (true) { + std::function task; + { + std::unique_lock lock(mutex); + work_available.wait(lock, [this] { + return stop || !tasks.empty(); + }); + + if (stop && tasks.empty()) { + return; + } + + task = std::move(tasks.front()); + tasks.pop_front(); + } + + task(); + + if (pending_tasks.fetch_sub(1, std::memory_order_acq_rel) == 1) { + std::lock_guard lock(mutex); + work_done.notify_one(); + } + } + } + + public: + explicit ThreadPool(size_t num_threads = std::thread::hardware_concurrency()) + : stop(false), pending_tasks(0) { + num_workers_ = std::min(num_threads, MAX_WORKERS); + if (num_workers_ == 0) num_workers_ = 1; + +#if defined(__ANDROID__) + auto& topo = CoreTopology::get(); + if (!topo.performance_cores.empty()) { + num_workers_ = std::min(num_workers_, topo.performance_cores.size()); + } +#endif + + workers.reserve(num_workers_); + for (size_t i = 0; i < num_workers_; ++i) { + workers.emplace_back([this]() { +#if defined(__ANDROID__) + auto& perf = CoreTopology::get().performance_cores; + if (!perf.empty()) { + pin_current_thread_to_cores(perf); + } +#endif + worker_thread(); + }); + } + } + + ~ThreadPool() { + { + std::lock_guard lock(mutex); + stop = true; + } + work_available.notify_all(); + for (auto& worker : workers) { + if (worker.joinable()) { + worker.join(); + } + } + } + + template + auto enqueue(F&& f) -> std::future { + using return_type = decltype(f()); + + auto task = std::make_shared>( + std::forward(f) + ); + + std::future res = task->get_future(); + + { + std::lock_guard lock(mutex); + pending_tasks.fetch_add(1, std::memory_order_relaxed); + tasks.emplace_back([task](){ (*task)(); }); + } + work_available.notify_one(); + + return res; + } + + template + void enqueue_batch(size_t total_work, F task_func) { + if (total_work == 0) return; + + const size_t num_tasks = std::min(num_workers_, total_work); + const size_t per_worker = total_work / num_tasks; + const size_t remainder = total_work % num_tasks; + + { + std::lock_guard lock(mutex); + pending_tasks.fetch_add(num_tasks, std::memory_order_relaxed); + + for (size_t w = 0; w < num_tasks; ++w) { + size_t start = w * per_worker + std::min(w, remainder); + size_t end = start + per_worker + (w < remainder ? 1 : 0); + tasks.emplace_back([=]() { task_func(start, end); }); + } + } + work_available.notify_all(); + } + + void wait_all() { + std::unique_lock lock(mutex); + work_done.wait(lock, [this] { + return pending_tasks.load(std::memory_order_acquire) == 0; + }); + } + + template + void enqueue_n_threads(size_t total_work, size_t num_threads, F task_func) { + if (total_work == 0 || num_threads == 0) return; + + num_threads = std::min(num_threads, std::min(num_workers_, total_work)); + const size_t per_thread = total_work / num_threads; + const size_t remainder = total_work % num_threads; + + { + std::lock_guard lock(mutex); + pending_tasks.fetch_add(num_threads, std::memory_order_relaxed); + + for (size_t t = 0; t < num_threads; ++t) { + size_t start = t * per_thread + std::min(t, remainder); + size_t end = start + per_thread + (t < remainder ? 1 : 0); + tasks.emplace_back([=]() { task_func(start, end); }); + } + } + work_available.notify_all(); + } + + size_t num_workers() const { return num_workers_; } + }; + + inline ThreadPool& get_thread_pool() { + static ThreadPool pool; + return pool; + } + + struct ParallelConfig { + size_t min_work_gate; + size_t work_per_thread; + + constexpr ParallelConfig(size_t gate, size_t per_thread) + : min_work_gate(gate), work_per_thread(per_thread) {} + }; + + inline size_t get_optimal_thread_count(size_t total_work, ParallelConfig config) { + if (total_work < config.min_work_gate) return 1; + + size_t pool_size = get_thread_pool().num_workers(); + size_t num_threads = (total_work + config.work_per_thread - 1) / config.work_per_thread; + return std::min(pool_size, std::max(static_cast(1), num_threads)); + } + + struct Thresholds { + #if defined(__ANDROID__) + static constexpr ParallelConfig ATTENTION{64, 32}; + static constexpr ParallelConfig ELEMENT_WISE{5000, 2500}; + static constexpr ParallelConfig AXIS_REDUCE{1000, 500}; + static constexpr ParallelConfig ALL_REDUCE{10000, 5000}; + static constexpr ParallelConfig SCALAR_BASIC{30000, 15000}; + static constexpr ParallelConfig SCALAR_EXPENSIVE{10000, 5000}; + #else // Apple + static constexpr ParallelConfig ATTENTION{32, 16}; + static constexpr ParallelConfig ELEMENT_WISE{5000, 2500}; + static constexpr ParallelConfig AXIS_REDUCE{1000, 500}; + static constexpr ParallelConfig ALL_REDUCE{10000, 5000}; + static constexpr ParallelConfig SCALAR_BASIC{5000, 2500}; + static constexpr ParallelConfig SCALAR_EXPENSIVE{2500, 1250}; + #endif + }; + + struct GemmThreading { + #if defined(__ANDROID__) + static size_t get_num_threads(size_t M, size_t pool_size) { + if (M <= 1) return 1; + return pool_size; + } + static size_t get_gemv_threads(size_t /*N_blocks*/, size_t /*pool_size*/) { + return 1; + } + #elif defined(__APPLE__) && TARGET_OS_IPHONE + static constexpr size_t GEMV_MIN_N_BLOCKS = 512; + static size_t get_num_threads(size_t M, size_t pool_size) { + if (M <= 1) return std::min(pool_size, static_cast(2)); + return pool_size; + } + static size_t get_gemv_threads(size_t N_blocks, size_t pool_size) { + if (N_blocks < GEMV_MIN_N_BLOCKS) return 1; + return std::min(pool_size, static_cast(3)); + } + #else + static constexpr size_t GEMV_MIN_N_BLOCKS = 256; + static size_t get_num_threads(size_t M, size_t pool_size) { + if (M <= 1) return std::min(pool_size, static_cast(4)); + return pool_size; + } + static size_t get_gemv_threads(size_t N_blocks, size_t pool_size) { + if (N_blocks < GEMV_MIN_N_BLOCKS) return 1; + if (N_blocks < 512) return std::min(pool_size, static_cast(2)); + return std::min(pool_size, static_cast(5)); + } + #endif + }; + + inline size_t& get_gemm_thread_override() { + static size_t override_threads = 0; + return override_threads; + } + + inline void set_gemm_threads(size_t num_threads) { + get_gemm_thread_override() = num_threads; + } + + inline void reset_gemm_threads() { + get_gemm_thread_override() = 0; + } + + class TaskHandle { + private: + std::vector> futures_; + bool auto_wait_; + + public: + TaskHandle(bool auto_wait = true) : auto_wait_(auto_wait) {} + + ~TaskHandle() { + if (auto_wait_) { + wait(); + } + } + + TaskHandle(TaskHandle&&) = default; + TaskHandle& operator=(TaskHandle&&) = default; + TaskHandle(const TaskHandle&) = delete; + TaskHandle& operator=(const TaskHandle&) = delete; + + void add_future(std::future&& f) { + futures_.push_back(std::move(f)); + } + + void wait() { + for (auto& f : futures_) { + if (f.valid()) { + f.wait(); + } + } + futures_.clear(); + } + + bool is_ready() const { + for (const auto& f : futures_) { + if (f.valid() && f.wait_for(std::chrono::seconds(0)) != std::future_status::ready) { + return false; + } + } + return true; + } + + size_t task_count() const { return futures_.size(); } + }; + + template + TaskHandle parallel_for(size_t total_work, ParallelConfig config, WorkFunc work_func, bool wait = true) { + const size_t num_threads = get_optimal_thread_count(total_work, config); + TaskHandle handle(!wait); + + if (num_threads == 1) { + if (wait) { + work_func(0, total_work); + return handle; + } + auto& pool = get_thread_pool(); + handle.add_future(pool.enqueue([work_func, total_work]() { + work_func(0, total_work); + })); + return handle; + } + + auto& pool = get_thread_pool(); + const size_t work_per_thread = total_work / num_threads; + + for (size_t t = 0; t < num_threads; ++t) { + handle.add_future(pool.enqueue([work_func, t, num_threads, work_per_thread, total_work]() { + const size_t start_idx = t * work_per_thread; + const size_t end_idx = (t == num_threads - 1) ? total_work : (t + 1) * work_per_thread; + work_func(start_idx, end_idx); + })); + } + + if (wait) { + handle.wait(); + } + return handle; + } + + template + void parallel_for_2d(size_t outer_size, size_t inner_size, ParallelConfig config, WorkFunc work_func) { + const size_t total_work = outer_size * inner_size; + parallel_for(total_work, config, [&](size_t start_idx, size_t end_idx) { + for (size_t work_idx = start_idx; work_idx < end_idx; ++work_idx) { + const size_t outer = work_idx / inner_size; + const size_t inner = work_idx % inner_size; + work_func(outer, inner); + } + }); + } + + template + ResultType parallel_reduce(size_t total_work, ParallelConfig config, + WorkFunc work_func, ResultType init_value, CombineFunc combine_func) { + const size_t num_threads = get_optimal_thread_count(total_work, config); + + if (num_threads == 1) { + return work_func(0, total_work); + } + + auto& pool = get_thread_pool(); + std::vector> futures; + std::vector partial_results(num_threads, init_value); + const size_t work_per_thread = total_work / num_threads; + + for (size_t t = 0; t < num_threads; ++t) { + futures.push_back(pool.enqueue([work_func, t, num_threads, work_per_thread, total_work]() -> ResultType { + const size_t start_idx = t * work_per_thread; + const size_t end_idx = (t == num_threads - 1) ? total_work : (t + 1) * work_per_thread; + return work_func(start_idx, end_idx); + })); + } + + ResultType result = init_value; + for (auto& future : futures) { + result = combine_func(result, future.get()); + } + return result; + } + + template + void parallel_gemm_tiles(size_t M, size_t total_tiles, WorkFunc work_func) { + auto& pool = get_thread_pool(); + + size_t override = get_gemm_thread_override(); + size_t num_threads = (override > 0) ? override : GemmThreading::get_num_threads(M, pool.num_workers()); + num_threads = std::min(num_threads, total_tiles); + + if (num_threads <= 1) { + work_func(0, total_tiles); + return; + } + + pool.enqueue_n_threads(total_tiles, num_threads, work_func); + pool.wait_all(); + } + +} + +template +void elementwise_op_f16(const __fp16* input, __fp16* output, size_t num_elements, + bool use_streaming, CactusThreading::ParallelConfig config, + SimdOp simd_op, ScalarOp scalar_op, size_t unroll = 4) { + CactusThreading::parallel_for(num_elements, config, + [&](size_t start, size_t end) { + const size_t n = end - start; + const size_t vec_end = start + simd_align(n); + + if (use_streaming && unroll >= 4) { + const size_t unrolled_end = start + simd_align(n, SIMD_F16_WIDTH * 4); + for (size_t i = start; i < unrolled_end; i += SIMD_F16_WIDTH * 4) { + __builtin_prefetch(&input[i + 256], 0, 0); + float16x8_t v0 = simd_op(vld1q_f16(&input[i])); + float16x8_t v1 = simd_op(vld1q_f16(&input[i + 8])); + float16x8_t v2 = simd_op(vld1q_f16(&input[i + 16])); + float16x8_t v3 = simd_op(vld1q_f16(&input[i + 24])); + stream_store_f16x8(&output[i], v0); + stream_store_f16x8(&output[i + 8], v1); + stream_store_f16x8(&output[i + 16], v2); + stream_store_f16x8(&output[i + 24], v3); + } + for (size_t i = unrolled_end; i < vec_end; i += SIMD_F16_WIDTH) { + stream_store_f16x8(&output[i], simd_op(vld1q_f16(&input[i]))); + } + } else if (use_streaming && unroll >= 2) { + const size_t unrolled_end = start + simd_align(n, SIMD_F16_WIDTH * 2); + for (size_t i = start; i < unrolled_end; i += SIMD_F16_WIDTH * 2) { + __builtin_prefetch(&input[i + 128], 0, 0); + float16x8_t v0 = simd_op(vld1q_f16(&input[i])); + float16x8_t v1 = simd_op(vld1q_f16(&input[i + 8])); + stream_store_f16x8(&output[i], v0); + stream_store_f16x8(&output[i + 8], v1); + } + for (size_t i = unrolled_end; i < vec_end; i += SIMD_F16_WIDTH) { + stream_store_f16x8(&output[i], simd_op(vld1q_f16(&input[i]))); + } + } else { + for (size_t i = start; i < vec_end; i += SIMD_F16_WIDTH) { + vst1q_f16(&output[i], simd_op(vld1q_f16(&input[i]))); + } + } + for (size_t i = vec_end; i < end; ++i) { + output[i] = scalar_op(input[i]); + } + }); +} + +#endif // KERNEL_UTILS_H diff --git a/Frameworks/cactus-ios.xcframework/ios-arm64/cactus.framework/Info.plist b/Frameworks/cactus-ios.xcframework/ios-arm64/cactus.framework/Info.plist new file mode 100644 index 00000000..ab00ff54 Binary files /dev/null and b/Frameworks/cactus-ios.xcframework/ios-arm64/cactus.framework/Info.plist differ diff --git a/Frameworks/cactus-ios.xcframework/ios-arm64/cactus.framework/Modules/module.modulemap b/Frameworks/cactus-ios.xcframework/ios-arm64/cactus.framework/Modules/module.modulemap new file mode 100644 index 00000000..daa01413 --- /dev/null +++ b/Frameworks/cactus-ios.xcframework/ios-arm64/cactus.framework/Modules/module.modulemap @@ -0,0 +1,5 @@ +framework module cactus { + umbrella header "cactus_ffi.h" + export * + module * { export * } +} diff --git a/Frameworks/cactus-ios.xcframework/ios-arm64/cactus.framework/cactus b/Frameworks/cactus-ios.xcframework/ios-arm64/cactus.framework/cactus new file mode 100755 index 00000000..7c5867b5 Binary files /dev/null and b/Frameworks/cactus-ios.xcframework/ios-arm64/cactus.framework/cactus differ diff --git a/LINEAR_ISSUES.md b/LINEAR_ISSUES.md new file mode 100644 index 00000000..4ff8fa1d --- /dev/null +++ b/LINEAR_ISSUES.md @@ -0,0 +1,283 @@ +# TacNet -- Linear Project Structure + +> Copy-paste these into Linear to create issues for your team. +> Workspace: yc-hack | Team: YC | Project: TacNet + +--- + +## PROJECT OVERVIEW + +**TacNet**: Decentralized tactical communication app (iOS) with on-device AI summarization via Gemma 4 E4B / Cactus SDK over BLE mesh. + +**Current Status**: All 22 implementation features DONE. 119 unit tests passing. Manual device testing pending. + +**Repo**: https://github.com/Nalin-Atmakur/YC-hack (main branch) + +**Key Files**: +- `Orchestrator.md` -- Full system spec +- `DECISIONS.md` -- 21 design decisions +- `SETUP_LOG.md` -- SDK/model setup notes +- `MANUAL_TESTING.md` -- Step-by-step device testing guide (53 assertions) +- `TacNet.xcodeproj` -- Xcode project + +--- + +## COMPLETED FEATURES (mark as Done in Linear) + +### Milestone 1: Foundation + +**YC-F01: Xcode Project Setup** [Done] +- Created TacNet.xcodeproj with SwiftUI, iOS 16+ target +- Integrated Cactus SDK XCFramework + Cactus.swift +- Added CoreBluetooth, AVFoundation, CoreLocation, SwiftData frameworks +- Info.plist permissions for Bluetooth, Microphone, Location + +**YC-F02: Data Models** [Done] +- TreeNode, NetworkConfig, Message envelope, NodeIdentity (all Codable) +- Version-based convergence logic +- GPS coordinate embedding in all messages +- 9 unit tests passing + +**YC-F03: Tree Helpers & Message Deduplicator** [Done] +- parent/siblings/children/level lookup utilities +- UUID-based seen-set with bounded growth (ring buffer, capacity 50k) +- 17 tests passing (cumulative) + +**YC-F04: BLE Mesh Service** [Done] +- CBCentralManager + CBPeripheralManager running simultaneously +- TacNet service UUID, GATT characteristics (broadcast, compaction, tree config) +- Message flooding with UUID dedup and TTL decrement/drop +- Connection state tracking per peer +- 24 tests passing (cumulative) + +**YC-F05: Model Download Service** [Done] +- Checks storage before download, progress [0.0-1.0] with resume support +- Download gate blocks app until complete +- Cactus SDK init wrapper +- Mock URLSession tests for progress, storage, gate logic + +### Milestone 2: Tree & Roles + +**YC-F06: Tree Builder & Network Config** [Done] +- TreeBuilderView: add/remove/rename nodes, set network name + PIN +- Version increments per operation +- WelcomeView: Create Network / Join Network flow +- Tests for all operations including edge cases (empty tree, deep tree, Unicode labels) + +**YC-F07: Network Discovery & Join** [Done] +- BLE scan for nearby networks (name, open slots) +- PIN authentication gate (wrong PIN rejected, PIN-less direct join) +- Full tree JSON transfer via BLE on join +- Version-based convergence (higher wins, out-of-order handled) +- 41 tests passing (cumulative) + +**YC-F08: Role Claiming Protocol** [Done] +- CLAIM/RELEASE/CLAIM_REJECTED message handling +- RoleSelectionView with claimed/open status +- Organiser-wins conflict resolution +- Auto-release after 60s BLE disconnect +- 47 tests passing (cumulative) + +**YC-F09: Live Tree Modification** [Done] +- Add/remove/rename/move nodes post-publish with TREE_UPDATE broadcast +- Remove claimed node kicks user with notification +- PROMOTE message for organiser transfer +- Claim preservation on unrelated edits + +### Milestone 3: Communications Core + +**YC-F10: PTT & Transcription** [Done] +- AVAudioEngine recording (16kHz mono 16-bit PCM) +- cactusTranscribe integration +- Empty/silence audio filtering, long audio cap, rapid PTT serialization + +**YC-F11: Message Routing** [Done] +- BROADCAST: visible to siblings + parent only +- COMPACTION: visible to parent only +- Grandparent/cousin filtering +- Envelope construction with GPS embedding + +**YC-F12: Compaction Engine** [Done] +- Time window, count threshold, priority keyword triggers +- Whole-word case-insensitive keyword match (no substring false positives) +- Tactical summarizer prompt (preserve critical info, under 30 words, remove filler) +- Root SITREP from L1 compactions + +**YC-F13: Main Screen Live Feed** [Done] +- Live feed with sender role, timestamp, type badge +- PTT button state machine (idle->recording->sending->idle) +- Disconnected state blocks PTT with error +- 76 tests passing (cumulative) + +### Milestone 4: Full UX + +**YC-F14: Tab Navigation & Tree View** [Done] +- 4-tab shell (Main, Tree View, Data Flow, Settings) +- TreeView with live status indicators (green/amber/red) +- Compaction summaries inline at parent nodes +- claimed_by info on each node + +**YC-F15: Data Flow Screen** [Done] +- INCOMING: all received messages with timestamp/sender/type +- PROCESSING: Gemma 4 status, trigger reason, latency, tokens, compression ratio +- OUTGOING: emitted compactions with destination, source IDs, text + +**YC-F16: Settings & Organiser Controls** [Done] +- Release Role button -> role selection +- Organiser-only Edit Tree and Promote controls +- Tree editor sheet for add/remove/rename from Settings + +**YC-F17: SwiftData Persistence** [Done] +- All BROADCAST/COMPACTION messages persisted with metadata +- Full-text search (case-insensitive) for after-action review +- Messages survive app restart +- 91 tests passing (cumulative) + +**YC-F18: Drag-and-Drop Tree Editor** [Done] +- Reparent nodes by dragging onto new parent +- Reorder siblings by dragging within same parent +- TREE_UPDATE broadcast on each operation +- Order persists across restart +- 95 tests passing (cumulative) + +### Milestone 5: Resilience + +**YC-F19: E2E Encryption** [Done] +- AES-256 (AES.GCM) with PIN-derived session key +- Key exchange on network join +- All messages encrypted in transit and at rest (SwiftData) +- Key material never logged +- 98 tests passing (cumulative) + +**YC-F20: Auto-Reparenting** [Done] +- Detect parent disconnect (60s timeout) +- Children traverse to nearest connected ancestor +- TREE_UPDATE with new parent_id broadcast +- Cascading multi-level disconnect handled + +**YC-F21: Priority Escalation + Model Download UI** [Done] +- Priority keywords bypass normal compaction thresholds +- False-positive guards (contacted, emergency exit, etc.) +- Download progress UI with model name, percentage, bytes, progress bar +- Storage check, resume support, gate enforcement + +**YC-F22: Cross-Area Integration** [Done] +- Scene-phase handling (background flush, foreground restart) +- 14 cross-area integration tests covering all end-to-end flows +- 119 tests passing (final) + +--- + +## MANUAL TESTING SPRINT (create as To Do) + +> See MANUAL_TESTING.md for detailed step-by-step instructions. +> Requires 4 iPhones with iOS 16+. Build/install via Xcode. + +**YC-T01: First Launch & Model Download** [To Do] +- Fresh install on Phone 0 +- Observe download progress UI, verify gate blocks features +- Verify download completes and app unlocks +- Assertions: VAL-RES-005, VAL-RES-006, VAL-CROSS-001 (partial) +- Time estimate: 15-30 min (depends on download speed) + +**YC-T02: BLE Discovery & Mesh** [To Do] +- 2-3 phones: verify peer discovery, bidirectional connections +- GATT characteristic verification +- Connection state tracking +- Assertions: VAL-BLE-001, VAL-BLE-002, VAL-BLE-007, VAL-BLE-008 +- Time estimate: 10 min + +**YC-T03: Network Creation & Role Claiming** [To Do] +- All 4 phones: create network, join, claim roles +- PIN entry (correct/wrong), conflict resolution +- Live tree modification, promote +- Assertions: VAL-TREE-006 through VAL-TREE-019, VAL-TREE-024, VAL-TREE-025 +- Time estimate: 20 min + +**YC-T04: Communication Flow** [To Do] +- All 4 phones: PTT, transcription, broadcast routing, compaction +- Verify live feed content and routing rules +- Assertions: VAL-COMM-001, VAL-COMM-015, VAL-COMM-017 +- Time estimate: 15 min + +**YC-T05: Full Demo Scenario (Section 14)** [To Do] +- All 4 phones: exact demo reproduction +- Alpha-1 speaks -> Alpha Lead compacts -> Commander SITREP +- Assertions: VAL-CROSS-002, VAL-CROSS-007 +- Time estimate: 10 min + +**YC-T06: Tree Modifications & Drag-and-Drop** [To Do] +- Organiser adds/removes/renames nodes live +- Drag-and-drop reparent with peers observing +- Assertions: VAL-UX-013, VAL-UX-017, VAL-CROSS-003 +- Time estimate: 10 min + +**YC-T07: Resilience -- Reparenting & Priority** [To Do] +- Power off Phone 1, observe auto-reparent +- Say "casualty" for priority escalation +- Organiser promote +- Assertions: VAL-RES-003, VAL-RES-004, VAL-RES-010, VAL-CROSS-004, VAL-CROSS-005, VAL-CROSS-009 +- Time estimate: 15 min + +**YC-T08: Encryption** [To Do] +- Create network with PIN, join, exchange messages +- Wrong PIN attempt +- Assertions: VAL-RES-001, VAL-RES-002, VAL-CROSS-008 +- Time estimate: 10 min + +**YC-T09: Message Flooding & TTL** [To Do] +- Multi-hop relay across 3-4 phones +- TTL edge cases (phones physically separated) +- Assertions: VAL-BLE-003 through VAL-BLE-006, VAL-BLE-009 +- Time estimate: 15 min + +**YC-T10: UX Polish Verification** [To Do] +- Tab navigation, tree view indicators, Data Flow screen +- After-action review search, Settings release role +- Assertions: VAL-UX-004, VAL-UX-005, VAL-CROSS-006, VAL-CROSS-010 +- Time estimate: 15 min + +**YC-T11: Edge Cases** [To Do] +- App backgrounding during compaction +- Download interruption (airplane mode toggle) +- GPS coordinates in messages +- Assertions: VAL-CROSS-012, VAL-CROSS-013, VAL-CROSS-014 +- Time estimate: 15 min + +--- + +## KNOWN ITEMS (create as Backlog) + +**YC-B01: Swift Sendable Warnings** [Backlog] +- BluetoothMeshService.swift has Sendable-conversion warnings for cactusComplete and Date.init closures +- Non-blocking, cosmetic. Fix by wrapping in @Sendable closures. +- Priority: Low + +**YC-B02: iPhone 16 Simulator Flakiness** [Backlog] +- Intermittent FBSOpenApplicationServiceErrorDomain on iPhone 16 simulator +- Workaround: use iPhone 17 simulator destination +- Not a code issue, Xcode/simulator bug +- Priority: None (workaround in place) + +--- + +## QUICK REFERENCE FOR TEAMMATES + +**To build and run**: +1. Open `TacNet.xcodeproj` in Xcode +2. Select your iPhone as destination (not simulator for BLE testing) +3. Build & Run (Cmd+R) +4. First launch downloads 6.7GB model -- needs WiFi + +**To run unit tests**: +``` +xcodebuild test -project TacNet.xcodeproj -scheme TacNet -destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' +``` + +**Phone assignments for demo**: +- Phone 0: Commander (root node) +- Phone 1: Alpha Lead (intermediate) +- Phone 2: Bravo Lead (intermediate) +- Phone 3: Alpha-1 (leaf node under Alpha Lead) + +**Total manual testing time**: ~2.5 hours with 4 phones diff --git a/MANUAL_TESTING.md b/MANUAL_TESTING.md new file mode 100644 index 00000000..6b5cd389 --- /dev/null +++ b/MANUAL_TESTING.md @@ -0,0 +1,802 @@ +# TacNet Manual Testing Guide + +> **Purpose:** Step-by-step physical device testing for all assertions that cannot be verified by unit tests alone. +> **Estimated time:** 90–120 minutes (linear, single session). +> **Date:** 2026-04-15 + +--- + +## Prerequisites + +### Devices Required + +| Label | Phone | Role | Notes | +|-------|-------|------|-------| +| **Phone 0** | iPhone (iOS 16+) | Commander (Root) | Organiser device — creates the network | +| **Phone 1** | iPhone (iOS 16+) | Alpha Lead (L1) | Intermediate node — compacts child messages | +| **Phone 2** | iPhone (iOS 16+) | Bravo Lead (L1) | Leaf node under root | +| **Phone 3** | iPhone (iOS 16+) | Alpha-1 (L2) | Leaf node under Alpha Lead | + +All 4 phones must be iPhone 15 or 16 series (8 GB RAM minimum for Gemma 4 E4B inference at INT4). + +**Physically label each phone** with tape or a sticky note: `Phone 0`, `Phone 1`, `Phone 2`, `Phone 3`. + +### Build & Install + +1. Open `/Users/yifuzuo/Desktop/yifu/startup/projects/hackathon/YC-hack/TacNet.xcodeproj` in Xcode. +2. Select your Apple Developer team under **Signing & Capabilities** for the `TacNet` target. +3. For each phone: + - Connect via USB. + - Select the device in Xcode's destination picker. + - `Cmd+R` to build and run. + - Trust the developer certificate on the phone: **Settings → General → VPN & Device Management**. +4. Repeat for all 4 phones. Keep Xcode console open on a Mac to view logs (connect via USB or wireless debugging). + +### Model Weights (First Launch) + +- The app downloads **6.7 GB** of Gemma 4 E4B INT4 weights on first launch. +- **Ensure all phones are on WiFi** before first launch. +- Download takes 5–15 minutes depending on connection speed. +- You can install and launch on all 4 phones simultaneously to download in parallel. +- After download completes once, the weights persist across app relaunches. + +### Console Log Access + +- Connect each phone to a Mac via USB. +- Open **Console.app** (macOS) or Xcode's debug console. +- Filter by process name `TacNet` to see relevant logs. +- Alternatively, use `Xcode → Debug → Attach to Process` for phones not actively running from Xcode. + +### Tree Topology for Testing + +All tests use this hierarchy (created in Phase 3): + +``` +Phone 0: Commander (Root) +├── Phone 1: Alpha Lead (L1) +│ └── Phone 3: Alpha-1 (L2) +└── Phone 2: Bravo Lead (L1) +``` + +--- + +## Phase 1: First Launch & Model Download + +**Phones used:** Phone 0 only (fresh install) +**Goal:** Verify model download progress UI, feature gating, and Cactus SDK initialization. + +> If you already downloaded the model during setup, delete and reinstall the app on Phone 0 to test fresh. + +### Step 1.1: Fresh Install — Download Progress UI + +1. **Action (Phone 0):** Launch the app for the first time on WiFi. +2. **Observe (Phone 0):** A download progress screen appears showing: + - A percentage indicator (0% → 100%) + - Progress updates monotonically (never jumps backward) +3. **Observe (Phone 0):** At least 5 intermediate progress updates appear between 0% and 100%. + +- [ ] **Pass / Fail** — Verifies: **VAL-RES-005** + +### Step 1.2: Feature Gate During Download + +1. **Action (Phone 0):** While download is in progress (between 10%–90%), attempt to navigate to any tactical feature (Create Network, Join Network, Push-to-Talk). +2. **Observe (Phone 0):** All tactical features are blocked/grayed out. The app does not allow creating or joining a network until download completes. +3. **Observe (Phone 0):** Within 3 seconds of download completion, features become available. + +- [ ] **Pass / Fail** — Verifies: **VAL-RES-006** + +### Step 1.3: Cactus SDK Initialization After Download + +1. **Action (Phone 0):** Wait for download to complete. The app should automatically initialize the Cactus SDK. +2. **Observe (Console log, Phone 0):** Look for log messages indicating: + - `cactusInit` succeeded (non-nil handle returned) + - A trivial `cactusComplete` call returned a non-empty string + - `cactusDestroy` completed without crash (or the context is held for reuse) +3. **Observe (Phone 0):** The app transitions to the main screen (Create/Join Network) without errors. + +- [ ] **Pass / Fail** — Verifies: **VAL-FOUND-008**, **VAL-CROSS-001** (first part: model download → cactusInit) + +--- + +## Phase 2: BLE Discovery & Mesh Formation + +**Phones used:** Phone 0, Phone 1 (add Phone 2 in Step 2.3) +**Goal:** Verify BLE peer discovery, bidirectional connections, and GATT characteristics. + +### Step 2.1: Peer Discovery via TacNet Service UUID + +1. **Action (Phone 0):** Tap **Create Network**. The app begins BLE advertising. +2. **Action (Phone 1):** Open the app (model already downloaded). Tap **Join Network**. The app begins scanning. +3. **Observe (Phone 1):** Within 10 seconds, Phone 0's network appears in the nearby networks list. +4. **Observe (Console log, both phones):** `didDiscover` callbacks logged with TacNet service UUID. + +- [ ] **Pass / Fail** — Verifies: **VAL-BLE-001** + +### Step 2.2: Bidirectional Central + Peripheral Operation + +1. **Observe (Console log, Phone 0):** Both `CBCentralManager` and `CBPeripheralManager` are active simultaneously. +2. **Observe (Console log, Phone 1):** Both `CBCentralManager` and `CBPeripheralManager` are active simultaneously. +3. **Observe (Console log, both phones):** `didConnect` callbacks appear on both phones (each connects to the other). +4. **Action:** From the console, confirm write operations succeed in both directions (Phone 0 → Phone 1 and Phone 1 → Phone 0). + +- [ ] **Pass / Fail** — Verifies: **VAL-BLE-002** + +### Step 2.3: GATT Characteristic Verification + +1. **Action:** Using a BLE scanner app (e.g., **nRF Connect** on a separate device, or check console logs), inspect Phone 0's advertised service. +2. **Observe:** The TacNet service UUID is discoverable. +3. **Observe:** The following characteristics are present with correct properties: + - **Broadcast characteristic:** Read, Write, Notify + - **Compaction characteristic:** Read, Write, Notify + - **Tree config characteristic:** Read + +- [ ] **Pass / Fail** — Verifies: **VAL-BLE-007** + +### Step 2.4: Connection State Tracking + +1. **Action (Phone 1):** Toggle Airplane Mode ON on Phone 1. +2. **Observe (Console log, Phone 0):** State for Phone 1 changes to `disconnected` with timestamp. +3. **Action (Phone 1):** Toggle Airplane Mode OFF. +4. **Observe (Console log, Phone 0):** State for Phone 1 changes to `connected` with timestamp. +5. **Observe:** State transitions are logged accurately with timestamps for connect, disconnect, and reconnect events. + +- [ ] **Pass / Fail** — Verifies: **VAL-BLE-008** + +--- + +## Phase 3: Network Creation & Role Claiming + +**Phones used:** All 4 phones +**Goal:** Create the network on Phone 0, join on others, claim roles, test PIN and conflict handling. + +### Step 3.1: Create & Publish Network (Phone 0) + +1. **Action (Phone 0):** In the tree builder, create this hierarchy: + - Root node: rename to **"Commander"** + - Add child: rename to **"Alpha Lead"** + - Under Alpha Lead, add child: rename to **"Alpha-1"** + - Add another child under root: rename to **"Bravo Lead"** +2. **Action (Phone 0):** Set network name to **"Test Network"**. +3. **Action (Phone 0):** Set a 4-digit PIN: **1234**. +4. **Action (Phone 0):** Tap **Publish Network**. +5. **Observe (Console log, Phone 0):** `CBPeripheralManager` starts advertising with TacNet service UUID and network name. +6. **Observe:** Using a BLE scanner or Phone 1's join screen, confirm the network is visible. + +- [ ] **Pass / Fail** — Verifies: **VAL-TREE-006** + +### Step 3.2: Discover Nearby Networks (Phones 1, 2, 3) + +1. **Action (Phone 1):** Tap **Join Network**. +2. **Observe (Phone 1):** Within 10 seconds, **"Test Network"** appears with name and open slot count (3 open slots). +3. **Repeat** for Phone 2 and Phone 3. + +- [ ] **Pass / Fail** — Verifies: **VAL-TREE-007** + +### Step 3.3: PIN Authentication — Wrong PIN + +1. **Action (Phone 1):** Tap **"Test Network"** to join. +2. **Observe (Phone 1):** A PIN entry screen appears. +3. **Action (Phone 1):** Enter wrong PIN: **0000**. +4. **Observe (Phone 1):** Error message displayed. No tree data is shown. Phone 1 remains on the join screen. + +- [ ] **Pass / Fail** — Verifies: **VAL-TREE-008** (wrong PIN path) + +### Step 3.4: PIN Authentication — Correct PIN + +1. **Action (Phone 1):** Enter correct PIN: **1234**. +2. **Observe (Phone 1):** PIN accepted. Tree view appears showing all 4 nodes with their labels. All nodes except Commander show as **Available/Open**. + +- [ ] **Pass / Fail** — Verifies: **VAL-TREE-008** (correct PIN path) + +### Step 3.5: Tree Sync on Join + +1. **Observe (Phone 1):** The tree displayed on Phone 1 exactly matches Phone 0's tree: + - Commander (claimed by Phone 0) + - Alpha Lead (open) + - Alpha-1 (open) + - Bravo Lead (open) +2. **Verify:** Node names, hierarchy, and claim status all match. + +- [ ] **Pass / Fail** — Verifies: **VAL-TREE-009** + +### Step 3.6: PIN-Less Network (Quick Detour) + +1. **Action (Phone 0):** Unpublish the current network. Create a new network with **no PIN**. Publish it. +2. **Action (Phone 2):** Tap **Join Network**, then tap the new network. +3. **Observe (Phone 2):** No PIN prompt appears. Phone 2 goes directly to the tree/role selection view. +4. **Action:** Unpublish this test network. Re-publish the original **"Test Network"** with PIN **1234**. + +- [ ] **Pass / Fail** — Verifies: **VAL-TREE-024** + +### Step 3.7: Join Remaining Phones & Claim Roles + +1. **Action (Phone 1):** Tap **"Alpha Lead"** node → **Claim this role**. +2. **Action (Phone 2):** Join with PIN **1234**. Tap **"Bravo Lead"** → **Claim this role**. +3. **Action (Phone 3):** Join with PIN **1234**. Tap **"Alpha-1"** → **Claim this role**. + +### Step 3.8: Claim Propagation Across All Peers + +1. **Observe (All 4 phones):** Within 5 seconds of each claim: + - **Phone 0:** Shows Commander (self), Alpha Lead (Phone 1), Bravo Lead (Phone 2), Alpha-1 (Phone 3) — all claimed. + - **Phone 1, 2, 3:** Each shows the same claim status on all nodes. +2. **Verify:** The CLAIM message propagated to every peer and all tree views are consistent. + +- [ ] **Pass / Fail** — Verifies: **VAL-TREE-010** + +### Step 3.9: Claim Conflict — Simultaneous Claim + +1. **Action (Phone 0):** Unpublish and recreate the network. Add a new open node **"Scout"** under Commander. Publish. +2. **Action:** Have Phone 1 and Phone 2 rejoin the network. +3. **Action (Phone 1 and Phone 2 simultaneously):** Both tap **"Scout"** → **Claim this role** at the exact same moment. +4. **Observe:** The organiser (Phone 0) wins the conflict. The loser receives a `CLAIM_REJECTED` message and returns to role selection. All peers show a consistent state. + +- [ ] **Pass / Fail** — Verifies: **VAL-TREE-011**, **VAL-CROSS-011** + +> **After this step:** Restore the standard 4-node topology. Make sure all phones are joined and roles claimed as: +> Phone 0 = Commander, Phone 1 = Alpha Lead, Phone 2 = Bravo Lead, Phone 3 = Alpha-1. + +### Step 3.10: TREE_UPDATE Does Not Reset Existing Claims + +1. **Action (Phone 0):** In the tree editor, add a new node **"Alpha-2"** under Alpha Lead. +2. **Observe (All 4 phones):** The new node appears on all devices within 5 seconds. +3. **Observe (All 4 phones):** Existing claims on Commander, Alpha Lead, Bravo Lead, and Alpha-1 are **unchanged**. No one gets kicked out. + +- [ ] **Pass / Fail** — Verifies: **VAL-TREE-025** + +> **Cleanup:** Remove the "Alpha-2" node before proceeding. + +--- + +## Phase 4: Communication Flow + +**Phones used:** All 4 phones +**Goal:** Test PTT, transcription, broadcast routing, compaction, SITREP, and live feed. + +### Step 4.1: PTT Audio Capture (Phone 3) + +1. **Action (Phone 3):** Navigate to the Main tab. Press and hold the **Push-to-Talk** button. +2. **Observe (Phone 3):** The PTT button visually changes to a "recording" state. +3. **Action (Phone 3):** Say clearly: **"Enemy spotted near building 4"**. Release the PTT button. +4. **Observe (Phone 3):** The button transitions through: recording → sending → idle. +5. **Observe (Console log, Phone 3):** Audio buffer metadata logged: 16 kHz, mono, 16-bit PCM. + +- [ ] **Pass / Fail** — Verifies: **VAL-COMM-001**, **VAL-UX-004** + +### Step 4.2: Broadcast Received at Sibling & Parent + +1. **Observe (Phone 1 — Alpha Lead, parent of Phone 3):** The transcript **"Enemy spotted near building 4"** (or close approximation) appears in the live feed with: + - Sender role: Alpha-1 + - Timestamp + - Type badge: BROADCAST +2. **Observe (Phone 0 — Commander, grandparent):** The raw transcript does **NOT** appear in the live feed (grandparent is excluded from broadcast routing). +3. **Observe (Phone 2 — Bravo Lead, uncle node):** The raw transcript does **NOT** appear in the live feed (different subtree). + +- [ ] **Pass / Fail** — Verifies: **VAL-COMM-015** (live feed display with metadata) + +### Step 4.3: Compaction at Alpha Lead (Phone 1) + +1. **Action (Phone 3):** Send 2 more PTT messages: + - PTT: **"They have rifles, moving toward the west entrance"** + - PTT: **"Requesting backup from Alpha Lead"** +2. **Observe (Phone 1):** After the compaction trigger fires (3 messages or time window), a compaction summary appears in the live feed with a distinct **COMPACTION** badge. +3. **Observe (Console log, Phone 1):** Compaction engine log shows: + - Input: 3 transcripts from Alpha-1 + - Output: Summary ≤ 30 words preserving key details (building 4, rifles, west entrance, backup) + +- [ ] **Pass / Fail** — Verifies: **VAL-COMM-015** (compaction in feed), **VAL-CROSS-002** (partial: leaf → parent compaction) + +### Step 4.4: Compacted Summary Reaches Commander (Phone 0) + +1. **Observe (Phone 0):** The compacted summary from Alpha Lead appears in the live feed with: + - Sender role: Alpha Lead + - Type badge: COMPACTION + - Summary text (not raw transcripts) +2. **Observe (Phone 0):** The raw transcripts from Alpha-1 are **NOT** visible — only the compacted summary. + +- [ ] **Pass / Fail** — Verifies: **VAL-CROSS-002** (compaction routes to parent only, raw doesn't leak to grandparent) + +### Step 4.5: Bravo Lead Contribution (Phone 2) + +1. **Action (Phone 2):** PTT: **"Bravo in position east side, all clear"**. +2. **Observe (Phone 0):** Bravo Lead's transcript or compaction appears in the live feed (Bravo Lead is a direct child of Commander, so the broadcast is visible to Phone 0). + +- [ ] **Pass / Fail** — Verifies: **VAL-COMM-015** + +### Step 4.6: Root SITREP (Phone 0) + +1. **Observe (Phone 0):** After receiving compactions from both Alpha Lead and Bravo Lead, Phone 0's compaction engine runs and produces a top-level **SITREP**. +2. **Observe (Phone 0):** The SITREP appears on screen summarizing both squads (e.g., "Alpha reports contact building 4. Bravo in position, clear."). + +- [ ] **Pass / Fail** — Verifies: **VAL-CROSS-002** (full cycle: leaf → compaction → root SITREP) + +### Step 4.7: Very Long Audio Recording + +1. **Action (Phone 3):** Press and hold PTT for **90 seconds** while speaking continuously. +2. **Observe (Phone 3):** No crash, no out-of-memory error. The recording is either capped at a maximum duration or fully processed. +3. **Observe:** A transcript is produced (may be truncated). The app remains responsive. + +- [ ] **Pass / Fail** — Verifies: **VAL-COMM-017** + +--- + +## Phase 5: Full Demo Scenario (Section 14) + +**Phones used:** All 4 phones +**Goal:** Reproduce the exact hackathon demo scenario end-to-end within 2 minutes. + +> **Reset:** Clear any pending compactions. Ensure all phones are on their claimed roles and live feeds are clear. + +### Step 5.1: Alpha-1 Reports Contact + +1. **Action (Phone 3 — Alpha-1):** PTT: **"Enemy spotted near building 4"**. +2. **Observe (Phone 1 — Alpha Lead):** Transcript appears in live feed immediately. + +### Step 5.2: Alpha Lead Auto-Compacts + +1. **Observe (Phone 1):** After compaction trigger, a summary appears (e.g., "Alpha: Enemy contact near building 4"). +2. **Observe (Phone 0 — Commander):** The compacted summary from Alpha Lead appears on screen. + +### Step 5.3: Bravo Reports + +1. **Action (Phone 2 — Bravo Lead):** PTT: **"Bravo in position, all clear"**. +2. **Observe (Phone 0):** Bravo Lead's message appears in the live feed. + +### Step 5.4: Commander SITREP + +1. **Observe (Phone 0):** Commander's compaction engine produces a SITREP combining both squads. +2. **Observe (Phone 0):** SITREP text on screen (e.g., "SITREP: Alpha reports contact bldg 4. Bravo in position, clear."). + +### Step 5.5: Timing Verification + +1. **Verify:** The entire demo flow (Step 5.1 through 5.4) completed in **under 2 minutes**. +2. **Verify:** No internet connection was used. Toggle WiFi off on all phones before starting if desired. + +- [ ] **Pass / Fail** — Verifies: **VAL-CROSS-002**, **VAL-CROSS-007** + +--- + +## Phase 6: Tree Modifications & Reparenting + +**Phones used:** All 4 phones +**Goal:** Test live tree editing (add, remove, rename) and drag-and-drop reparenting by the organiser. + +### Step 6.1: Organiser Adds Node Post-Publish + +1. **Action (Phone 0):** Navigate to Settings or Tree Editor. Add a new child node under Alpha Lead named **"Alpha-2"**. +2. **Observe (All 4 phones):** Within 3 seconds, the new **"Alpha-2"** node appears in the tree view on all devices. +3. **Observe:** The new node shows as **Available/Open**. + +- [ ] **Pass / Fail** — Verifies: **VAL-UX-017** (add propagation) + +### Step 6.2: Organiser Renames Node + +1. **Action (Phone 0):** Rename **"Alpha-2"** to **"Alpha Medic"**. +2. **Observe (All 4 phones):** Within 3 seconds, the label updates to **"Alpha Medic"** on all devices. +3. **Observe:** If the node were claimed, the claim would be preserved (in this case it's unclaimed). + +- [ ] **Pass / Fail** — Verifies: **VAL-UX-017** (rename propagation) + +### Step 6.3: Organiser Removes Node + +1. **Action (Phone 0):** Remove the **"Alpha Medic"** node. +2. **Observe (All 4 phones):** Within 3 seconds, the node disappears from all devices. + +- [ ] **Pass / Fail** — Verifies: **VAL-UX-017** (remove propagation) + +### Step 6.4: Drag-and-Drop Reparenting + +1. **Action (Phone 0):** Long-press on **"Alpha-1"** (currently under Alpha Lead) and drag it onto **"Commander"** (root) to reparent it. +2. **Observe (Phone 0):** Alpha-1 now appears as a direct child of Commander in the tree. +3. **Observe (All other phones):** The tree updates within 3 seconds to show the new hierarchy. +4. **Observe:** Phone 3's claim on Alpha-1 is **preserved** — it is still claimed by Phone 3. +5. **Observe (Console log):** A `TREE_UPDATE` message was broadcast to all peers. + +- [ ] **Pass / Fail** — Verifies: **VAL-UX-013**, **VAL-CROSS-003** (partial: tree restructure) + +### Step 6.5: Routing Updates After Reparent + +1. **Action (Phone 3 — Alpha-1, now under Commander):** PTT: **"Testing new routing after reparent"**. +2. **Observe (Phone 0 — Commander, now Alpha-1's parent):** The transcript appears in Phone 0's live feed (new parent receives it). +3. **Observe (Phone 1 — Alpha Lead, old parent):** The transcript does **NOT** appear in Phone 1's live feed (no longer parent). + +- [ ] **Pass / Fail** — Verifies: **VAL-CROSS-003** (routing rules update after reparent) + +> **Cleanup:** Reparent Alpha-1 back under Alpha Lead to restore the standard topology. + +--- + +## Phase 7: Resilience Testing + +**Phones used:** All 4 phones +**Goal:** Test auto-reparenting on disconnect, priority escalation, and organiser promotion. + +### Step 7.1: Auto-Reparenting — Parent Disconnect + +1. **Verify setup:** Standard topology: Commander → Alpha Lead → Alpha-1, Commander → Bravo Lead. +2. **Action (Phone 1 — Alpha Lead):** Power off Phone 1 completely (hold side button → slide to power off). +3. **Start a timer.** +4. **Observe (Phone 3 — Alpha-1):** Within 55–65 seconds, Alpha-1 is automatically reparented to **Commander** (the nearest connected ancestor). +5. **Observe (Phone 0 — Commander):** The tree view updates to show Alpha-1 as a direct child. A `TREE_UPDATE` is broadcast. +6. **Record the time:** Should be 60s ± 5s from power-off. + +- [ ] **Pass / Fail** — Verifies: **VAL-RES-003** + +### Step 7.2: Routing After Reparent + +1. **Action (Phone 3 — Alpha-1, now under Commander):** PTT: **"Test message after auto-reparent"**. +2. **Observe (Phone 0 — Commander):** The transcript or compaction from Alpha-1 appears in Phone 0's live feed. +3. **Observe:** Data Flow screen on Phone 0 shows the message in INCOMING with Alpha-1 as sender. + +- [ ] **Pass / Fail** — Verifies: **VAL-RES-004** + +### Step 7.3: Power Phone 1 Back On + +1. **Action:** Power Phone 1 back on and relaunch TacNet. +2. **Action (Phone 1):** Rejoin the network and reclaim Alpha Lead. +3. **Action (Phone 0):** Reparent Alpha-1 back under Alpha Lead to restore standard topology. + +### Step 7.4: Cascading Multi-Level Reparent + +> This test requires a deeper tree. Temporarily modify the topology. + +1. **Action (Phone 0):** Add a new node **"Alpha-1-Sub"** under Alpha-1. Have no phone claim it (or use a 5th phone if available). The point is to verify cascading logic. +2. **Actual test with 4 phones (simplified):** With the standard topology, first disconnect Phone 1 (Alpha Lead). Phone 3 (Alpha-1) reparents to Commander (tested in 7.1). Now also observe that if Phone 3 were disconnected too, its hypothetical children would reparent further up. +3. **Alternative verification (Console log, Phone 0):** Check that the auto-reparent logic traverses upward through the tree correctly. Log output should show the algorithm finding the nearest connected ancestor. + +- [ ] **Pass / Fail** — Verifies: **VAL-RES-010** + +### Step 7.5: Priority Escalation — "Casualty" Keyword + +1. **Verify setup:** Standard topology restored, all phones connected. +2. **Action (Phone 3 — Alpha-1):** PTT: **"We have a casualty, need medevac immediately"**. +3. **Observe (Phone 1 — Alpha Lead):** Compaction triggers **immediately** (within 5 seconds) — no waiting for the normal message count or time window. +4. **Observe (Console log, Phone 1):** Compaction trigger reason logged as **"priority keyword: casualty"**. +5. **Observe (Phone 0 — Commander):** The compacted summary reaches Commander faster than normal compaction cycle time. + +- [ ] **Pass / Fail** — Verifies: **VAL-CROSS-009** + +### Step 7.6: Organiser Promote + +1. **Action (Phone 0):** Navigate to Settings or the tree editor. Select Phone 1 (Alpha Lead) and tap **Promote to Organiser**. +2. **Observe (Phone 0):** Phone 0 loses organiser controls (Edit Tree button disappears or becomes disabled). +3. **Observe (Phone 1):** Phone 1 gains organiser controls (Edit Tree button appears and is enabled). +4. **Observe (All phones):** No dual-organiser state. The transition is atomic. + +- [ ] **Pass / Fail** — Verifies: **VAL-CROSS-005** (partial: promote) + +### Step 7.7: New Organiser Edits Tree + +1. **Action (Phone 1 — new organiser):** Add a node **"Alpha-2"** under Alpha Lead. +2. **Observe (All 4 phones):** The new node appears within 3 seconds on all devices. +3. **Action (Phone 1):** Remove the **"Alpha-2"** node. +4. **Observe:** Communication continues uninterrupted throughout organiser handover and edits. + +- [ ] **Pass / Fail** — Verifies: **VAL-CROSS-005** (full: promote → edit → propagate → comms uninterrupted) + +> **Cleanup:** Promote Phone 0 back to organiser (Phone 1 promotes Phone 0), or recreate the network from Phone 0. + +--- + +## Phase 8: Encryption + +**Phones used:** Phone 0, Phone 1, Phone 2 (Phone 3 optional as late joiner) +**Goal:** Verify encrypted communication with PIN-based key exchange. + +### Step 8.1: Create Encrypted Network + +1. **Action (Phone 0):** Create a new network with PIN **5678**. Build the standard tree. Publish. + +### Step 8.2: Join with Correct PIN & Exchange Messages + +1. **Action (Phone 1):** Join with PIN **5678**. Claim Alpha Lead. +2. **Action (Phone 2):** Join with PIN **5678**. Claim Bravo Lead. +3. **Action (Phone 2):** PTT: **"Bravo reporting, perimeter secure"**. +4. **Observe (Phone 0):** The message is received and displayed correctly (decrypted). +5. **Observe (Phone 1):** The message is received (if routing applies). + +- [ ] **Pass / Fail** — Verifies: **VAL-RES-002** (correct PIN decrypts) + +### Step 8.3: Attempt Wrong PIN + +1. **Action (Phone 3):** Attempt to join the network with wrong PIN **0000**. +2. **Observe (Phone 3):** Authentication rejected. Phone 3 cannot see tree data or read any messages. + +- [ ] **Pass / Fail** — Verifies: **VAL-RES-002** (wrong PIN cannot read), **VAL-CROSS-008** (partial) + +### Step 8.4: Verify Encryption in Transit + +1. **Action:** On a Mac with a BLE sniffer, or check console logs for raw BLE packet data. +2. **Observe:** BLE payloads contain no plaintext message fragments. Messages are encrypted before transmission. +3. **Alternatively (Console log):** Confirm no plaintext transcript strings appear in BLE write/read logs — only encrypted bytes. + +- [ ] **Pass / Fail** — Verifies: **VAL-RES-001** (in transit) + +### Step 8.5: Verify Encryption at Rest + +1. **Action (Phone 1):** Use Xcode's Devices & Simulators window to download the app container from Phone 1. +2. **Action:** Inspect the SwiftData database file (`.store` or `.sqlite`). +3. **Observe:** No plaintext message fragments in the database. Messages are encrypted at rest. + +- [ ] **Pass / Fail** — Verifies: **VAL-RES-001** (at rest) + +### Step 8.6: Late Joiner Gets Key + +1. **Action (Phone 3):** Now join with the correct PIN **5678**. Claim Alpha-1. +2. **Action (Phone 2):** Send another PTT message: **"Second report from Bravo"**. +3. **Observe (Phone 3):** Phone 3 can read the new message (received key on join). + +- [ ] **Pass / Fail** — Verifies: **VAL-CROSS-008** (full: PIN join → key exchange → late joiner) + +### Step 8.7: Encryption Key Not Leaked in Logs + +1. **Action:** On the Mac, grep the Xcode console output for all 4 phones. +2. **Search for:** PIN digits ("5678"), base64 strings of key length (32+ chars), or any key material. +3. **Observe:** No key material, PIN plaintext, or key-length base64 strings in console logs. + +- [ ] **Pass / Fail** — Verifies: **VAL-RES-011** + +--- + +## Phase 9: Message Flooding & TTL + +**Phones used:** Phone 0, Phone 1, Phone 2, Phone 3 +**Goal:** Test multi-hop message relay, TTL decrement, and UUID deduplication. + +> **Physical setup:** Place phones in a line. Ideally: +> - Phone 0 and Phone 3 should be far apart (different rooms if possible) +> - Phone 1 between Phone 0 and Phone 3 +> - Phone 2 near Phone 0 +> +> This ensures messages from Phone 3 may need to hop through Phone 1 to reach Phone 0. + +### Step 9.1: Message Flooding Across 3 Phones + +1. **Action (Phone 3 — Alpha-1):** Send a PTT message: **"Flood test from Alpha-1"**. +2. **Observe (Console log, Phone 1):** Message received from Phone 3 with original `sender_id` (Alpha-1) and TTL decremented by 1 from original. +3. **Observe (Console log, Phone 1):** Phone 1 re-broadcasts the message. +4. **Observe (Console log, Phone 0):** Message received (either directly from Phone 3 or relayed via Phone 1) with `sender_id` still showing Alpha-1 and TTL further decremented. +5. **Verify:** `sender_id` is preserved through hops. TTL is decremented at each hop. + +- [ ] **Pass / Fail** — Verifies: **VAL-BLE-003** + +### Step 9.2: TTL Decrement and Drop at Zero + +1. **Action:** If possible via debug settings or code, send a message with `TTL=2` from Phone 3. +2. **Observe (Console log, Phone 1):** Receives with TTL=1, re-broadcasts. +3. **Observe (Console log, Phone 0):** Receives with TTL=0, does **NOT** re-broadcast. +4. **Observe (Console log, Phone 2):** If Phone 2 is out of direct range of Phone 3 and Phone 1, it should **NOT** receive the message (TTL exhausted before reaching it). + +- [ ] **Pass / Fail** — Verifies: **VAL-BLE-004** + +### Step 9.3: TTL=1 Edge Case + +1. **Action:** Send a message with `TTL=1` from Phone 3. +2. **Observe (Console log, Phone 1):** Receives and processes locally but does **NOT** re-broadcast. +3. **Observe (Console log, Phone 0):** Does not receive this message (if out of direct range of Phone 3). + +- [ ] **Pass / Fail** — Verifies: **VAL-BLE-005** + +### Step 9.4: UUID Dedup Prevents Infinite Loops + +1. **Action (Phone 3):** Send a message. Due to mesh topology, the message may reach some phones via multiple paths. +2. **Observe (Console log, all phones):** Each phone logs exactly **one** `processMessage` event for this message UUID. No phone processes the same UUID twice. +3. **Verify:** No infinite relay loops. Message is seen once per device. + +- [ ] **Pass / Fail** — Verifies: **VAL-BLE-006** + +### Step 9.5: Store-and-Forward Across 4 Phones + +1. **Physical setup:** Arrange phones in a chain: Phone 0 ↔ Phone 1 ↔ Phone 2 ↔ Phone 3 (each only in BLE range of its neighbors). +2. **Action (Phone 3):** Send a PTT message: **"Chain relay test"**. +3. **Observe (Console log, Phone 2):** Receives from Phone 3, relays. +4. **Observe (Console log, Phone 1):** Receives from Phone 2, relays. +5. **Observe (Console log, Phone 0):** Receives from Phone 1. `sender_id` is still Alpha-1 (Phone 3). TTL decremented 3 times from original. +6. **Verify:** Timestamps show the message hopping through each intermediate phone. + +- [ ] **Pass / Fail** — Verifies: **VAL-BLE-009** + +--- + +## Phase 10: UX Polish Verification + +**Phones used:** Any phone (Phone 0 recommended, or use multiple for live data) +**Goal:** Verify tab navigation, tree status indicators, Data Flow screen, after-action review, and settings. + +### Step 10.1: Tab Navigation + +1. **Action (Phone 0):** Tap through all 4 tabs: **Main**, **Tree View**, **Data Flow**, **Settings**. +2. **Observe:** Each tab renders its root view correctly. No blank screens, no error states. +3. **Observe:** Navigation is responsive (under 300ms per tab switch — subjective check for snappiness). + +- [ ] **Pass / Fail** — Verifies: **VAL-UX-004** (tab navigation component) + +### Step 10.2: Tree View Status Indicators + +1. **Verify setup:** All 4 phones connected and active. +2. **Action (Phone 0):** Navigate to Tree View tab. +3. **Observe (Phone 0):** All claimed nodes show **green** (active) indicators. +4. **Action:** Set Phone 3 down and don't interact with it for 30+ seconds. +5. **Observe (Phone 0):** Phone 3's node (Alpha-1) transitions to **amber** (idle >30s). +6. **Action:** Toggle Airplane Mode ON on Phone 3 and wait 60+ seconds. +7. **Observe (Phone 0):** Phone 3's node transitions to **red** (disconnected >60s). +8. **Action:** Toggle Airplane Mode OFF on Phone 3. +9. **Observe (Phone 0):** Phone 3's node transitions back to **green** (after reconnection). + +- [ ] **Pass / Fail** — Verifies: **VAL-UX-005** + +### Step 10.3: Data Flow Screen During Active Comms + +1. **Action (Phone 1 — Alpha Lead):** Navigate to the **Data Flow** tab. +2. **Action (Phone 3 — Alpha-1):** Send 3 PTT messages in sequence. +3. **Observe (Phone 1 — Data Flow):** + - **INCOMING section:** Shows all 3 messages with timestamps, sender (Alpha-1), and type (BROADCAST). + - **PROCESSING section:** Shows compaction status ("Compacting (3 msgs)"), trigger reason, latency, token counts, and compression ratio. + - **OUTGOING section:** Shows the emitted compaction with destination (Commander), source IDs, and output text. + +- [ ] **Pass / Fail** — Verifies: **VAL-CROSS-010** + +### Step 10.4: After-Action Review Search + +1. **Action (Phone 0):** Navigate to after-action review / message history. +2. **Action (Phone 0):** Search for **"building"** (from earlier messages). +3. **Observe:** Matching messages appear (both BROADCAST and COMPACTION types) with metadata. +4. **Action (Phone 0):** Search for **"xyznonexistent"**. +5. **Observe:** 0 results returned. +6. **Action (Phone 0):** Search for **"BUILDING"** (uppercase). +7. **Observe:** Same results as lowercase search (case-insensitive). + +- [ ] **Pass / Fail** — Verifies: **VAL-CROSS-006** + +### Step 10.5: Settings — Release Role + +1. **Action (Phone 2 — Bravo Lead):** Navigate to **Settings** tab. Tap **Release Role**. +2. **Observe (Phone 2):** Navigates back to role selection screen. +3. **Observe (All other phones):** Bravo Lead node reverts to **Available/Open** within 5 seconds. +4. **Observe (Phone 2):** Historical message data is still intact (after-action review still has Bravo Lead's messages). +5. **Action (Phone 2):** Reclaim Bravo Lead. + +- [ ] **Pass / Fail** — Verifies: **VAL-UX-015**, **VAL-TREE-013** + +--- + +## Phase 11: Edge Cases + +**Phones used:** Phone 0, Phone 3 (others as needed) +**Goal:** Test app backgrounding, download interruption, and GPS coordinate embedding. + +### Step 11.1: App Backgrounding During Compaction + +1. **Action (Phone 3 — Alpha-1):** Send 2 PTT messages to Alpha Lead. +2. **Action (Phone 1 — Alpha Lead):** Immediately after the 3rd PTT message from Phone 3, press the **Home button** (or swipe up) to background the TacNet app on Phone 1. +3. **Wait 5 seconds.** +4. **Action (Phone 1):** Reopen TacNet. +5. **Observe (Phone 1):** The app returns to the correct state. No data loss. The compaction either completed in the background or resumes correctly. +6. **Observe (Phone 1):** BLE reconnects to peers (check console log for reconnection events). + +- [ ] **Pass / Fail** — Verifies: **VAL-CROSS-013** + +### Step 11.2: Model Download Interruption and Recovery + +> Requires a fresh install or cleared app data on one phone. + +1. **Action (Phone 0):** Delete and reinstall TacNet. Launch on WiFi. Download begins. +2. **Action (Phone 0):** At approximately 30–50% progress, toggle **Airplane Mode ON** to interrupt the download. +3. **Observe (Phone 0):** An error UI appears indicating the download was interrupted. +4. **Action (Phone 0):** Toggle **Airplane Mode OFF**. +5. **Action (Phone 0):** Tap **Retry** (or the app auto-retries). +6. **Observe (Phone 0):** Download resumes from approximately the same point (not from 0%). +7. **Wait** for download to complete. +8. **Observe (Phone 0):** `cactusInit` succeeds after download completion. The app transitions to the main screen. + +- [ ] **Pass / Fail** — Verifies: **VAL-CROSS-012**, **VAL-RES-005** (resume component) + +### Step 11.3: GPS Coordinates in Messages + +1. **Action (Phone 3):** Ensure Location Services are enabled for TacNet (Settings → Privacy → Location Services → TacNet → While Using). +2. **Action (Phone 3):** Send a PTT message: **"GPS coordinate test"**. +3. **Observe (Console log, Phone 3):** The outgoing message envelope includes `location` field with `lat`, `lon`, and `accuracy` values matching the phone's actual GPS position. +4. **Observe (Console log, Phone 1 — receiver):** The received message contains the same GPS coordinates. +5. **Observe:** Query SwiftData on Phone 1 (via Xcode or console) to verify the GPS fields are persisted. + +- [ ] **Pass / Fail** — Verifies: **VAL-CROSS-014** + +### Step 11.4: Auto-Release After 60s BLE Disconnect + +1. **Verify:** Phone 3 has Alpha-1 claimed. +2. **Action (Phone 3):** Toggle **Airplane Mode ON** (simulates BLE disconnect). +3. **Start a timer.** +4. **Observe (Phone 0):** After 55–65 seconds, Alpha-1 reverts to **Available/Open** (auto-released). +5. **Action (Phone 3):** Toggle **Airplane Mode OFF** within 30 seconds of step 2 (before the 60s timeout) in a **separate test**. +6. **Observe (Phone 0):** Alpha-1's claim is **preserved** (reconnected before timeout). + +- [ ] **Pass / Fail** — Verifies: **VAL-TREE-014** + +### Step 11.5: Remove Claimed Node Kicks User + +1. **Verify:** Phone 3 has Alpha-1 claimed. Phone 1 has Alpha Lead claimed. +2. **Action (Phone 0 — Organiser):** In the tree editor, remove the **"Alpha-1"** node. +3. **Observe (Phone 3):** Phone 3 gets kicked back to the role selection screen with a notification that their node was removed. +4. **Observe (All phones):** Alpha-1 node is gone from the tree view. + +- [ ] **Pass / Fail** — Verifies: **VAL-TREE-016** + +> **Cleanup:** Re-add Alpha-1 node and have Phone 3 reclaim it. + +--- + +## Results Summary + +Record the outcome of each assertion below. Mark **✅** for pass or **❌** for fail. + +| Phase | Assertion ID | Description | Result | +|-------|-------------|-------------|--------| +| 1 | VAL-RES-005 | Model download progress UI | ⬜ | +| 1 | VAL-RES-006 | Model download gate blocks app | ⬜ | +| 1 | VAL-FOUND-008 | Cactus SDK initialization — success path | ⬜ | +| 1 | VAL-CROSS-001 | First-time user journey (download → init) | ⬜ | +| 2 | VAL-BLE-001 | Peer discovery via TacNet service UUID | ⬜ | +| 2 | VAL-BLE-002 | Bidirectional central + peripheral operation | ⬜ | +| 2 | VAL-BLE-007 | GATT characteristic setup | ⬜ | +| 2 | VAL-BLE-008 | Connection state tracking | ⬜ | +| 3 | VAL-TREE-006 | Network publish starts BLE advertising | ⬜ | +| 3 | VAL-TREE-007 | Participant discovers nearby networks | ⬜ | +| 3 | VAL-TREE-008 | PIN authentication gate (wrong + correct) | ⬜ | +| 3 | VAL-TREE-009 | Tree sync on join | ⬜ | +| 3 | VAL-TREE-010 | Claim open node updates all peers | ⬜ | +| 3 | VAL-TREE-011 | Claim conflict — organiser wins | ⬜ | +| 3 | VAL-TREE-024 | PIN-less network allows direct join | ⬜ | +| 3 | VAL-TREE-025 | TREE_UPDATE does not reset existing claims | ⬜ | +| 3 | VAL-CROSS-011 | Concurrent role claim conflict | ⬜ | +| 4 | VAL-COMM-001 | PTT audio capture start/stop | ⬜ | +| 4 | VAL-COMM-015 | Live feed display with metadata | ⬜ | +| 4 | VAL-COMM-017 | Very long audio recording (90s) | ⬜ | +| 4 | VAL-UX-004 | Push-to-talk button state machine | ⬜ | +| 4 | VAL-CROSS-002 | Full communication cycle (leaf → compaction → SITREP) | ⬜ | +| 5 | VAL-CROSS-007 | Demo scenario end-to-end (Section 14) | ⬜ | +| 6 | VAL-TREE-015 | Live tree modification — add node post-publish | ⬜ | +| 6 | VAL-TREE-017 | Rename node propagates to all peers | ⬜ | +| 6 | VAL-TREE-018 | Move node preserves claim | ⬜ | +| 6 | VAL-UX-013 | Drag-and-drop reparenting | ⬜ | +| 6 | VAL-UX-017 | Organiser live tree modification broadcast | ⬜ | +| 6 | VAL-CROSS-003 | Tree restructure mid-operation | ⬜ | +| 7 | VAL-TREE-019 | Organiser promote transfers role | ⬜ | +| 7 | VAL-UX-018 | Organiser promote transfers role (UX) | ⬜ | +| 7 | VAL-RES-003 | Auto-reparenting on parent disconnect | ⬜ | +| 7 | VAL-RES-004 | Routing rules update after reparent | ⬜ | +| 7 | VAL-RES-010 | Cascading multi-level reparent | ⬜ | +| 7 | VAL-CROSS-004 | Node failure recovery | ⬜ | +| 7 | VAL-CROSS-005 | Organiser handover | ⬜ | +| 7 | VAL-CROSS-009 | Priority escalation end-to-end | ⬜ | +| 8 | VAL-RES-001 | Message encryption at rest and in transit | ⬜ | +| 8 | VAL-RES-002 | Key exchange on network join | ⬜ | +| 8 | VAL-RES-011 | Encryption key not leaked in logs | ⬜ | +| 8 | VAL-CROSS-008 | Encrypted communication (full flow) | ⬜ | +| 9 | VAL-BLE-003 | Message flooding across mesh | ⬜ | +| 9 | VAL-BLE-004 | TTL decrement and drop at zero | ⬜ | +| 9 | VAL-BLE-005 | TTL edge case — message arrives with TTL=1 | ⬜ | +| 9 | VAL-BLE-006 | UUID dedup prevents infinite loops | ⬜ | +| 9 | VAL-BLE-009 | Store-and-forward across intermediate phones | ⬜ | +| 10 | VAL-UX-005 | Tree view real-time status indicators | ⬜ | +| 10 | VAL-CROSS-006 | After-action review search | ⬜ | +| 10 | VAL-CROSS-010 | Data flow transparency during active comms | ⬜ | +| 10 | VAL-UX-015 | Settings — release role returns to role selection | ⬜ | +| 10 | VAL-TREE-013 | Manual role release floods RELEASE | ⬜ | +| 11 | VAL-CROSS-012 | Model download interruption and recovery | ⬜ | +| 11 | VAL-CROSS-013 | Compaction persistence across app backgrounding | ⬜ | +| 11 | VAL-CROSS-014 | GPS coordinates through full message chain | ⬜ | +| 11 | VAL-TREE-014 | Auto-release after 60s BLE disconnect | ⬜ | +| 11 | VAL-TREE-016 | Remove claimed node kicks user | ⬜ | + +**Total assertions:** 53 | **Passed:** ___ / 53 | **Failed:** ___ / 53 + +--- + +### Notes + +_Use this space to record observations, issues, screenshots, or timestamps during testing._ + +| Assertion ID | Notes | +|-------------|-------| +| | | +| | | +| | | diff --git a/Orchestrator.md b/Orchestrator.md new file mode 100644 index 00000000..def2625f --- /dev/null +++ b/Orchestrator.md @@ -0,0 +1,1896 @@ +# TacNet: Tactical Communication Network with On-Device AI Summarization + + + +> A decentralized, offline-first voice communication system that mimics military radio hierarchy using a Bluetooth mesh of phones, each running Gemma 4 E4B (Edge 4B) via Cactus AI for real-time message compaction and upward propagation. + + + +--- + + + +## 1. The Problem + + + +In military operations (and disaster relief, construction sites, large events), radio communication hits a fundamental scaling wall: + + + +- **A commander with 50 subordinates cannot listen to 50 simultaneous voice channels** + +- Traditional radio requires humans to manually relay and summarize upward + +- Centralized systems (cell towers, internet) are single points of failure + +- Existing solutions require expensive proprietary hardware + + + +**TacNet solves this**: every phone in the network runs a local AI that automatically compresses child messages into summaries and propagates them up a command tree — so the top-level operator gets a real-time, AI-compacted situational overview without hearing a single raw transmission. + + + +--- + + + +## 2. System Architecture Overview + + + +``` + +┌─────────────┐ + +│ ROOT NODE │ Commander / HQ + +│ (Phone 0) │ Sees: compacted summary of ENTIRE network + +└──────┬──────┘ + +│ + +Compacted summary from L1 nodes + +│ + +┌────────────────┼────────────────┐ + +│ │ │ + +┌──────┴──────┐ ┌─────┴──────┐ ┌──────┴──────┐ + +│ L1 NODE │ │ L1 NODE │ │ L1 NODE │ + +│ (Phone 1) │ │ (Phone 2) │ │ (Phone 3) │ Platoon Leaders + +└──────┬──────┘ └────────────┘ └──────┬──────┘ + +│ │ + +Compacted summary from L2 Compacted summary from L2 + +│ │ + +┌───────┼───────┐ ┌────────┼────────┐ + +│ │ │ │ │ │ + +┌──┴──┐ ┌─┴──┐ ┌──┴──┐ ┌───┴──┐ ┌──┴───┐ ┌──┴──┐ + +│ L2 │ │ L2 │ │ L2 │ │ L2 │ │ L2 │ │ L2 │ + +│ P4 │ │ P5 │ │ P6 │ │ P7 │ │ P8 │ │ P9 │ Squad Members + +└─────┘ └─────┘ └─────┘ └──────┘ └──────┘ └─────┘ + + + +◄──── Siblings ────► ◄──── Siblings ────► + +(hear each other (hear each other + +via broadcast) via broadcast) + +``` + + + +--- + + + +## 3. Two Communication Layers + + + +The system operates on **two distinct layers** simultaneously: + + + +### Layer 1: Broadcast (Radio Replacement) + + + +``` + +┌─────────────────────────────────────────────────────────────┐ + +│ BROADCAST LAYER │ + +│ │ + +│ When Phone 4 pushes talk: │ + +│ │ + +│ 1. Phone 4 records audio and plays it LOCALLY │ + +│ 2. STT converts to transcript on-device (Cactus/Gemma) │ + +│ 3. Transcript crosses the BLE mesh to ALL phones │ + +│ │ + +│ Audio is NEVER transmitted over BLE — only transcript text │ + +│ crosses the mesh. No BLE audio profile is used. │ + +│ │ + +│ These nodes DISPLAY the transcript in their live feed: │ + +│ - Phone 5 (sibling) ✅ receives transcript via mesh │ + +│ - Phone 6 (sibling) ✅ receives transcript via mesh │ + +│ - Phone 1 (parent) ✅ receives transcript via mesh │ + +│ - Phone 0 (grandparent) ❌ filtered out (not sibling/parent) │ + +│ - Phone 7 (cousin) ❌ filtered out (not sibling/parent) │ + +│ │ + +│ Scope: siblings + immediate parent only │ + +│ Purpose: replaces traditional radio within a squad │ + +└─────────────────────────────────────────────────────────────┘ + +``` + + + +This mimics how a radio channel works — everyone on your channel (your siblings + your squad leader) sees your message. The key architectural difference: **audio is NEVER transmitted over BLE**; only the transcript text crosses the mesh. Receiving devices display the transcript in their live feed. No BLE audio profile is used. + + + +### Layer 2: Compaction (AI Summarization Upward) + + + +``` + +┌─────────────────────────────────────────────────────────────┐ + +│ COMPACTION LAYER │ + +│ │ + +│ Phone 1 (parent of P4, P5, P6) runs Gemma 4 locally: │ + +│ │ + +│ Input: │ + +│ - P4: Contact north side, 3 hostiles, engaging │ + +│ - P5: Moving to support P4, ETA 2 min │ + +│ - P6: South perimeter clear, holding position │ + +│ │ + +│ Gemma 4 compacts to: │ + +│ Squad Alpha: Contact north (P4 engaging, P5 │ + +│ reinforcing 2min). South clear (P6 holding). │ + +│ │ + +│ This summary is broadcast upward to Phone 0 (root). │ + +│ │ + +│ Phone 0 receives compacted summaries from ALL L1 nodes │ + +│ and runs Gemma 4 again to produce a top-level overview: │ + +│ │ + +│ SITREP: Alpha engaged north, reinforcing. │ + +│ Bravo holding east. Charlie advancing west on sched. │ + +│ │ + +└─────────────────────────────────────────────────────────────┘ + +``` + + + +--- + + + +## 4. Bluetooth Mesh Network + + + +``` + +┌──────┐ ┌──────┐ ┌──────┐ + +│ P1 │◄──BT───►│ P2 │◄──BT───►│ P3 │ + +└──┬───┘ └──┬───┘ └──┬───┘ + +│ │ │ + +BT BT BT + +│ │ │ + +┌──┴───┐ ┌──┴───┐ ┌──┴───┐ + +│ P4 │◄──BT───►│ P5 │◄──BT───►│ P6 │ + +└──┬───┘ └──┬───┘ └──────┘ + +│ │ + +BT BT + +│ │ + +┌──┴───┐ ┌──┴───┐ + +│ P7 │◄──BT───►│ P8 │ + +└──────┘ └──────┘ + + + +BT = Bluetooth Low Energy connection + +Every phone connects to all phones in BT range + +Messages hop through the mesh to reach all nodes + +``` + + + +**Key properties:** + +- **Fully decentralized** — no central server, no internet required + +- **Store-and-forward** — messages hop through intermediate phones + +- **All nodes receive all messages** — the mesh floods every transmission + +- **Logical filtering happens at the app layer** — each phone decides what to play/process based on the tree hierarchy + + + +--- + + + +## 5. Node Roles & Responsibilities + + + +Every phone in the network is identical software. The **tree configuration** determines its role: + + + +| Role | Responsibilities | What it hears | What it produces | + +|------|-----------------|---------------|-----------------| + +| **Leaf Node** | Push-to-talk voice messages | Sibling broadcasts + parent broadcasts | Transcript text (STT on-device) | + +| **Intermediate Node** | PTT + compaction of children | Sibling broadcasts + parent broadcasts + child broadcasts | Transcript text AND compacted summaries from children | + +| **Root Node** | Compaction of all L1 summaries | L1 compacted summaries | Top-level SITREP (situation report) | + +All messages carry embedded GPS coordinates (lat/lon/accuracy) from Core Location automatically. + + + +--- + + + +## 6. On-Device AI Stack + + + +``` + +┌─────────────────────────────────────────────┐ + +│ EACH PHONE RUNS: │ + +│ │ + +│ ┌───────────────────────────────────────┐ │ + +│ │ Cactus AI Runtime │ │ + +│ │ (Low-latency on-device inference) │ │ + +│ │ │ │ │ + +│ │ ┌─────────────────────────────────┐ │ │ + +│ │ │ Gemma 4 E4B Model │ │ │ + +│ │ │ (Google DeepMind's on-device │ │ │ + +│ │ │ multimodal model with native │ │ │ + +│ │ │ audio encoder — ~300M param │ │ │ + +│ │ │ audio conformer, no separate │ │ │ + +│ │ │ STT model needed) │ │ │ + +│ │ └─────────────────────────────────┘ │ │ + +│ └───────────────────────────────────────┘ │ + +│ │ + +│ ┌───────────────────────────────────────┐ │ + +│ │ Voice Processing Pipeline (2-step) │ │ + +│ │ │ │ + +│ │ Step 1: Mic ─► Gemma 4 E4B ─► Text │ │ + +│ │ (native audio conformer, STT) │ │ + +│ │ │ │ + +│ │ Step 2: Text ─► Gemma 4 E4B ─► │ │ + +│ │ Compacted Summary │ │ + +│ └───────────────────────────────────────┘ │ + +│ │ + +│ ┌───────────────────────────────────────┐ │ + +│ │ Bluetooth Mesh Module │ │ + +│ │ (Send/receive to all nearby phones) │ │ + +│ └───────────────────────────────────────┘ │ + +└─────────────────────────────────────────────┘ + +``` + + + +### Why Cactus + Gemma 4 E4B? + + + +- **Gemma 4 E4B** is Google DeepMind's on-device multimodal model with native audio input (~300M param audio conformer encoder) + +- **Single model** handles both STT and summarization — no separate Whisper/STT model needed + +- **E4B = Edge 4B** — 4.5B effective params, 8B with embeddings, ~2.8GB VRAM at INT4 + +- **Fast** — 30s audio processes in ~0.3s on Apple Silicon, 40 tok/s decode + +- **Cactus** provides the low-latency inference engine optimized for mobile/edge devices + +- **No internet required** — entire AI pipeline runs on the phone + +- **Hybrid routing** — if a phone has internet, complex tasks can optionally route to cloud (but the system works fully offline) + + + +--- + + + +## 7. Message Flow: Complete Example + + + +``` + +TIME ACTION + +───────────────────────────────────────────────────────────────── + + + +t=0 P4 (leaf) pushes talk: We've spotted movement in sector 7 + +│ + +├──► Bluetooth mesh floods message to ALL phones + +│ + +├──► P5, P6 (siblings): DISPLAY transcript ✅ (received via mesh) + +├──► P1 (parent): DISPLAY transcript ✅ (received via mesh) + QUEUE for compaction + +├──► P0, P2, P3, P7-P9: RECEIVE transcript but IGNORE (not sibling/parent) + + + +t=5 P5 (leaf) pushes talk: Confirmed, I see 4 individuals, armed + +│ + +├──► P4, P6: DISPLAY transcript ✅ (received via mesh) + +├──► P1 (parent): DISPLAY transcript ✅ (received via mesh) + QUEUE for compaction + + + +t=8 P6 (leaf) pushes talk: Rear is clear, no movement + +│ + +├──► P4, P5: DISPLAY transcript ✅ (received via mesh) + +├──► P1 (parent): DISPLAY transcript ✅ (received via mesh) + QUEUE for compaction + + + +t=10 P1's compaction triggers (time window / message threshold): + +│ + +│ Gemma 4 processes queued messages: + +│ IN: spotted movement sector 7 + +│ confirmed 4 armed individuals + +│ rear clear + +│ OUT: Squad-1: 4 armed contacts sector 7 (confirmed by 2), + +│ rear secure. + +│ + +└──► Compacted summary broadcast with COMPACTION tag + +│ + +└──► P0 (root): RECEIVES compacted summary ✅ + + + +t=12 P0 receives compacted summaries from P1, P2, P3: + +│ + +│ Gemma 4 compacts all L1 summaries: + +│ OUT: SITREP: Squad-1 has 4 armed contacts sector 7. + +│ Squad-2 holding perimeter east. Squad-3 advancing + +│ on schedule. + +│ + +└──► Displayed on root commander's screen as live SITREP + +``` + + + +--- + + + +## 8. Message Types & Protocol + + + +``` + +┌──────────────────────────────────────────────────────────┐ + +│ MESSAGE ENVELOPE │ + +├──────────────────────────────────────────────────────────┤ + +│ { │ + +│ id: uuid-v4, │ + +│ type: BROADCAST | COMPACTION | CLAIM | RELEASE | │ +│ TREE_UPDATE | PROMOTE | CLAIM_REJECTED, │ + +│ sender_id: node-uuid, │ + +│ sender_role: Alpha-2 (position in tree), │ + +│ parent_id: node-uuid, │ + +│ tree_level: 2, │ + +│ timestamp: 1713200000, │ + +│ ttl: 5, // mesh hop limit │ + +│ payload: { │ + +│ location: { lat, lon, accuracy }, // auto-embedded GPS │ + +│ encrypted: true, // E2E via pre-shared key │ + +│ payload: { │ + +│ transcript: ..., // STT result (BROADCAST only) │ + +│ summary: ..., // for COMPACTION only) │ + +│ source_ids: [...], // messages summarized │ + +│ // CLAIM/RELEASE/TREE_UPDATE/PROMOTE/CLAIM_REJECTED │ + +│ // type field carries intent; payload varies by type │ + +│ } │ + +│ } │ + +└──────────────────────────────────────────────────────────┘ + +``` + + + +### Routing Rules (App Layer) + + + +| Message Type | Sender | Who plays/displays it | + +|---|---|---| + +| `BROADCAST` | Any node | Sender's siblings + sender's parent | + +| `COMPACTION` | Intermediate/root node | That node's parent only | + + + +--- + + + +## 9. Tree Configuration — Organiser-Driven, Fluid Roles + + + +The system has two distinct user modes: **Organiser** (creates the hierarchy) and **Participant** (joins and claims a role). The tree is fully customisable — there are no hardcoded roles. + + + +### 9.1 Flow: Organiser Creates the Network + + + +The organiser (typically the commander / site lead) opens the app first and builds the tree from scratch using a drag-and-drop editor: + + + +``` + +┌─────────────────────────────────────────────┐ + +│ ORGANISER: BUILD YOUR NETWORK │ + +│ │ + +│ ┌─────────────────────────────────────┐ │ + +│ │ [+ Add Root Node] │ │ + +│ │ │ │ + +│ │ ┌──────────────┐ │ │ + +│ │ │ Commander │ ← tap to │ │ + +│ │ │ (rename) │ rename │ │ + +│ │ └──────┬───────┘ │ │ + +│ │ │ │ │ + +│ │ [+ Add Child] │ │ + +│ │ │ │ │ + +│ │ ┌────────┼────────┐ │ │ + +│ │ │ │ │ │ │ + +│ │ ┌──┴───┐ ┌──┴───┐ ┌──┴───┐ │ │ + +│ │ │Alpha │ │Bravo │ │ │ │ │ + +│ │ │Lead │ │Lead │ │[+ Add│ │ │ + +│ │ └──┬───┘ └──────┘ │ More]│ │ │ + +│ │ │ └──────┘ │ │ + +│ │ [+ Add Child] │ │ + +│ │ │ │ │ + +│ │ ┌──┴───┐ ┌──────┐ ┌──────┐ │ │ + +│ │ │A-1 │ │A-2 │ │[+] │ │ │ + +│ │ └──────┘ └──────┘ └──────┘ │ │ + +│ └─────────────────────────────────────┘ │ + +│ │ + +│ Network Name: [ Operation Nightfall ] │ + +│ Network PIN: [ 4-digit optional PIN ] │ + +│ │ + +│ [Publish Network] │ + +└─────────────────────────────────────────────┘ + +``` + + + +**Organiser capabilities:** + +- Name each node (free text — Alpha Lead, Medic, Drone Operator, Foreman, anything) + +- Add/remove children at any depth + +- Set a network name + optional PIN for access control + +- Reorder nodes via drag-and-drop + +- Publish the tree — this starts BLE advertising the network + + + +### 9.2 Flow: Participant Joins and Claims a Role + + + +When a participant opens the app, they see nearby TacNet networks. They tap to join, enter the PIN if required, and then see the full tree with **available / claimed** status on every node: + + + +``` + +┌─────────────────────────────────────────────┐ + +│ JOIN: Operation Nightfall │ + +│ │ + +│ Select your role: │ + +│ │ + +│ ┌──────────────────┐ │ + +│ │ Commander │ 🔴 Claimed │ + +│ │ (Organiser) │ by: iPhone-Jake │ + +│ └──────┬───────────┘ │ + +│ │ │ + +│ ┌────────┼────────┐ │ + +│ │ │ │ │ + +│ ┌──┴──────┐ │ ┌─────┴────┐ │ + +│ │ Alpha │ │ │ Charlie │ │ + +│ │ Lead │ │ │ Lead │ │ + +│ │ 🔴 Jake │ │ │ 🟢 OPEN │ ← tap to │ + +│ └──┬──────┘ │ └──────────┘ claim │ + +│ │ │ │ + +│ ┌──┴──┐ ┌──┴──────┐ │ + +│ │ A-1 │ │ Bravo │ │ + +│ │🟢OPEN│ │ Lead │ │ + +│ └─────┘ │ 🟡 Pending│ │ + +│ └──────────┘ │ + +│ │ + +│ 🔴 Claimed 🟡 Pending 🟢 Open │ + +│ │ + +│ [ Claim Charlie Lead ] │ + +└─────────────────────────────────────────────┘ + +``` + + + +**Participant flow:** + +1. App scans BLE → discovers nearby TacNet networks + +2. Tap a network → enter PIN if set → receive the tree JSON + +3. See all nodes with live claim status (synced via BLE) + +4. Tap an open node → **Claim this role** + +5. Claim broadcasts to all peers → node turns red (claimed) on everyone's screen + +6. Participant is now live in the network with routing rules active + + + +### 9.3 Role Claim Protocol + + + +``` + +┌──────────────────────────────────────────────────────────┐ + +│ ROLE CLAIM PROTOCOL │ + +│ │ + +│ 1. DISCOVER │ + +│ Participant scans BLE for TacNet service UUID │ + +│ Receives: network_name, node_count, open_slots │ + +│ │ + +│ 2. AUTHENTICATE │ + +│ If PIN set: participant enters PIN │ + +│ Organiser's phone validates → grants/denies │ + +│ │ + +│ 3. SYNC TREE │ + +│ Full tree JSON transferred via BLE │ + +│ Includes claim status for every node │ + +│ │ + +│ 4. CLAIM │ + +│ Participant taps open node → sends CLAIM message: │ + +│ { │ + +│ type: CLAIM, │ + +│ node_id: charlie-lead, │ + +│ device_id: iPhone-Sara, │ + +│ timestamp: 1713200000 │ + +│ } │ + +│ Flooded to all peers via mesh │ + +│ │ + +│ 5. CONFIRM │ + +│ All nodes update their local tree state │ + +│ If two devices claim the same node simultaneously: │ + +│ → organiser device wins automatically │ + +│ → loser receives CLAIM_REJECTED: organiser_wins │ + +│ → loser returns to role selection │ + +│ │ + +│ 6. RELEASE │ + +│ If a device disconnects or user taps Release Role: │ + +│ → RELEASE message flooded │ + +│ → Node goes back to 🟢 OPEN │ + +│ → Auto-release after 60s BLE disconnect timeout │ + +│ │ + +│ 7. LIVE UPDATES │ + +│ Tree state changes (claim/release/new nodes) │ + +│ are broadcast as TREE_UPDATE messages in the mesh │ + +│ Every phone stays in sync │ + +└──────────────────────────────────────────────────────────┘ + +``` + + + +### 9.4 Organiser Can Modify the Tree Live + + + +The organiser retains edit access even after publishing. They can: + + + +| Action | What happens | + +|---|---| + +| **Add a node** | `TREE_UPDATE` broadcast → all phones see the new open slot | + +| **Remove an empty node** | `TREE_UPDATE` broadcast → node disappears from everyone's tree | + +| **Remove a claimed node** | Claimed user gets kicked back to role selection with a notification | + +| **Rename a node** | `TREE_UPDATE` broadcast → label updates everywhere | + +| **Move a node** (re-parent) | `TREE_UPDATE` broadcast → routing rules update automatically | +| **Promote to organiser** | `PROMOTE` broadcast → target device gains organiser permissions; `created_by` updates; old organiser becomes participant | + + + +This means the hierarchy is **fluid during operation** — if the commander needs to restructure squads mid-mission, they edit the tree and everyone's routing updates instantly. + + + +### 9.5 Tree Config Data Model + + + +``` + +Example Tree Config (JSON) — as distributed over BLE: + +{ + +network_name: Operation Nightfall, + +network_id: uuid-v4, + +created_by: iPhone-Jake, + +pin_hash: sha256..., // null if no PIN + +version: 7, // increments on every edit + +tree: { + +id: commander, + +label: Commander, + +claimed_by: iPhone-Jake, + +children: [ + +{ + +id: alpha-lead, + +label: Alpha Lead, + +claimed_by: iPhone-Sara, + +children: [ + +{ id: alpha-1, label: Alpha-1, claimed_by: null }, + +{ id: alpha-2, label: Alpha-2, claimed_by: iPhone-Tom }, + +{ id: alpha-3, label: Alpha-3, claimed_by: null } + +] + +}, + +{ + +id: bravo-lead, + +label: Bravo Lead, + +claimed_by: null, + +children: [ + +{ id: bravo-1, label: Bravo-1, claimed_by: null }, + +{ id: bravo-2, label: Bravo-2, claimed_by: null } + +] + +} + +] + +} + +} + +``` + + + +The `version` field is key — when a phone receives a `TREE_UPDATE` with a higher version than its local copy, it replaces its tree. This ensures convergence across the mesh even if updates arrive out of order. + + + +--- + + + +## 10. Mobile UX + + + +``` + +┌─────────────────────────────────┐ + +│ TacNet v1.0 │ + +│ │ + +│ ┌─────────────────────────┐ │ + +│ │ LIVE FEED │ │ + +│ │ │ │ + +│ │ Alpha-2: Movement │ │ + +│ │ in sector 7 │ │ + +│ │ │ │ + +│ │ Alpha-3: Confirmed, │ │ + +│ │ 4 armed │ │ + +│ │ │ │ + +│ │ ─── COMPACTION ─── │ │ + +│ │ Squad Alpha: 4 armed │ │ + +│ │ contacts sector 7, │ │ + +│ │ rear secure. │ │ + +│ │ │ │ + +│ └─────────────────────────┘ │ + +│ │ + +│ ┌─────────────────────────┐ │ + +│ │ │ │ + +│ │ 🎙 PUSH TO TALK │ │ + +│ │ │ │ + +│ └─────────────────────────┘ │ + +│ │ + +│ [Config] [Tree View] [Map] │ + +└─────────────────────────────────┘ + +``` + + + +### Screens + + + +1. **Main Screen** — Live feed of broadcasts from siblings + compaction summaries from children. Large push-to-talk button. + +2. **Config Screen** — View the full tree. Tap a node to claim it as my position. Shows connection status of Bluetooth mesh peers. + +3. **Tree View** — Visual tree with live status indicators (active, idle, disconnected). Compaction summaries shown inline at parent nodes. + +4. **Data Flow Screen** — Transparent view of what the AI is doing on this phone. Shows raw input, processing status, and output. + + + +### Data Flow Tab (Screen 4) + + + +This screen gives full visibility into what data is entering the node, how Gemma 4 is processing it, and what is being emitted. Critical for debugging in the field and for the hackathon demo. + + + +``` + +┌─────────────────────────────────────┐ + +│ DATA FLOW — Alpha Lead │ + +│ │ + +│ ┌─ INCOMING ─────────────────────┐ │ + +│ │ │ │ + +│ │ 14:02:05 Alpha-1 [BROADCAST] │ │ + +│ │ Enemy spotted near bldg 4 │ │ + +│ │ │ │ + +│ │ 14:02:12 Alpha-2 [BROADCAST] │ │ + +│ │ Confirmed, 4 armed │ │ + +│ │ │ │ + +│ │ 14:02:30 Alpha-3 [BROADCAST] │ │ + +│ │ Rear clear, holding │ │ + +│ │ │ │ + +│ │ 14:02:35 HQ [COMPACTION ↓] │ │ + +│ │ All squads push to obj. │ │ + +│ └────────────────────────────────┘ │ + +│ │ + +│ ┌─ PROCESSING ───────────────────┐ │ + +│ │ ⚙ Gemma 4 via Cactus │ │ + +│ │ │ │ + +│ │ Status: ● Compacting (3 msgs) │ │ + +│ │ Trigger: msg_count >= 3 │ │ + +│ │ Latency: 340ms │ │ + +│ │ Model: gemma-4-2b-it │ │ + +│ │ │ │ + +│ │ Input tokens: 87 │ │ + +│ │ Output tokens: 22 │ │ + +│ │ Compression: 74.7% │ │ + +│ └────────────────────────────────┘ │ + +│ │ + +│ ┌─ OUTGOING ─────────────────────┐ │ + +│ │ │ │ + +│ │ 14:02:36 [COMPACTION → HQ] │ │ + +│ │ Squad Alpha: 4 armed │ │ + +│ │ contacts bldg 4 (2x conf). │ │ + +│ │ Rear secure. │ │ + +│ │ │ │ + +│ │ Sent to: HQ (parent) │ │ + +│ │ Summarized: 3 messages │ │ + +│ │ Source: Alpha-1, 2, 3 │ │ + +│ └────────────────────────────────┘ │ + +│ │ + +│ [Main] [Config] [Tree] [Flow] │ + +└─────────────────────────────────────┘ + +``` + + + +**Data Flow tab sections:** + + + +| Section | Contents | + +|---|---| + +| **INCOMING** | All messages this node receives and processes — broadcasts from children, compactions from below, orders from above. Timestamped, labeled by type. | + +| **PROCESSING** | Real-time status of the Gemma 4 compaction engine — is it idle, queuing, or actively compacting? Shows trigger reason, latency, token counts, and compression ratio. | + +| **OUTGOING** | Every compaction summary this node has produced and emitted upward. Shows destination, source messages summarized, and the output text. | + + + +--- + + + +## 11. Compaction Engine (Gemma 4 Prompt Design) + + + +The on-device Gemma 4 model is prompted with a structured template: + + + +``` + +SYSTEM: You are a tactical communications summarizer. Compress the + +following radio messages from your subordinates into a brief, actionable + +summary. Preserve: locations, threat counts, unit status, urgent items. + +Remove: filler, repetition, acknowledgements. Keep under 30 words. + + + +MESSAGES: + +- [Alpha-1, 14:02:05]: We've spotted movement in sector 7, over + +- [Alpha-2, 14:02:12]: Copy that, I can confirm, 4 individuals, armed + +- [Alpha-3, 14:02:30]: Rear perimeter all clear, no movement, holding + + + +SUMMARY: + +``` + + + +**Output**: `Squad Alpha: 4 armed contacts sector 7 (2x confirmed). Rear clear, holding.` + + + +### Compaction Triggers + + + +| Trigger | Description | + +|---------|-------------| + +| **Time window** | Every N seconds (configurable, e.g. 30s) | + +| **Message count** | After N messages from children (e.g. 3) | + +| **Priority keyword** | Immediately on words like contact, casualty, emergency | + + + +--- + + + +## 12. Technical Stack + + + +### Platform: Native iOS (Swift) + + + +| Layer | Technology | Notes | + +|---|---|---| + +| **Language** | Swift 5.9+ | Native iOS, no cross-platform overhead | + +| **UI Framework** | SwiftUI | Declarative UI for all 4 tabs | + +| **On-Device AI** | Cactus AI SDK (Swift) + Gemma 4 E4B | Cactus provides low-latency inference; Gemma 4 E4B handles both STT and summarization natively | + +| **Voice Input** | AVFoundation (`AVAudioEngine`) | Push-to-talk recording, raw audio capture | + +| **Speech-to-Text** | Gemma 4 E4B via Cactus (native audio encoder) | Native ~300M param audio conformer — not a separate STT model. On-device only, no internet. | + +| **Bluetooth Mesh** | Core Bluetooth (BLE) | `CBCentralManager` + `CBPeripheralManager` — each phone acts as both central and peripheral | + +| **Message Serialization** | `Codable` structs → JSON → BLE | Swift-native encoding, compact payloads over GATT characteristics | + +| **Local Storage** | SwiftData | Full message history with full-text search for after-action review. Ring buffer optional for storage limits. | + +| **Audio Playback** | AVFoundation (`AVAudioPlayer`) | Local recording feedback only — received messages are text transcripts displayed in feed, not played as audio. Model weights (6.7GB INT4) are downloaded on first launch, not bundled. | + +| **Concurrency** | Swift Concurrency (`async`/`await`, Actors) | BLE scanning, AI inference, and audio on separate actors to avoid blocking UI | + +| **Tree Config** | `Codable` JSON stored in app sandbox | Shared tree distributed via BLE handshake on first mesh connection | + +| **Minimum iOS** | iOS 16.0+ | Required for modern Swift Concurrency, SwiftData, and stable BLE mesh APIs. | + + + +### Architecture: Swift App Structure + + + +``` + +TacNet/ + +├── TacNetApp.swift # App entry point — routes to Onboarding or Main + +├── Models/ + +│ ├── TreeNode.swift # Tree hierarchy model (Codable, claimed_by, version) + +│ ├── NetworkConfig.swift # Network name, id, pin_hash, version, tree root + +│ ├── Message.swift # Message envelope (BROADCAST / COMPACTION / CLAIM / TREE_UPDATE) + +│ └── NodeIdentity.swift # Local state: I am this node + device ID + +├── Services/ + +│ ├── BluetoothMeshService.swift # Core Bluetooth central + peripheral + +│ │ # Handles discovery, flooding, dedup (by UUID) + +│ ├── NetworkDiscoveryService.swift # Scans for nearby TacNet networks (for participants) + +│ ├── RoleClaimService.swift # Handles CLAIM / RELEASE protocol + conflict resolution + +│ ├── TreeSyncService.swift # Distributes tree updates, version-based convergence + +│ ├── AudioService.swift # AVAudioEngine for record + AVAudioPlayer for playback + +│ ├── CompactionEngine.swift # Manages Gemma 4 E4B inference via Cactus SDK + +│ │ # Queues child messages, triggers compaction, emits summary + +│ ├── ModelDownloadService.swift # Handles first-launch model download with progress UI + +│ │ # Downloads Gemma 4 E4B weights (6.7GB INT4) on first run + +│ └── MessageRouter.swift # Decides: display transcript? queue for compaction? ignore? + +│ # Applies tree-based routing rules + +├── ViewModels/ + +│ ├── OnboardingViewModel.swift # Create vs Join network flow + +│ ├── TreeBuilderViewModel.swift # Organiser: add/remove/rename/reorder nodes + +│ ├── RoleSelectionViewModel.swift # Participant: browse tree, claim a node + +│ ├── MainViewModel.swift # Live feed + PTT state + +│ ├── TreeViewModel.swift # Visual tree with live claim indicators + +│ └── DataFlowViewModel.swift # Incoming / Processing / Outgoing streams + +├── Views/ + +│ ├── Onboarding/ + +│ │ ├── WelcomeView.swift # Create Network or Join Network + +│ │ ├── TreeBuilderView.swift # Organiser: drag-and-drop tree editor + +│ │ ├── NetworkScanView.swift # Participant: list of nearby networks + +│ │ ├── PinEntryView.swift # PIN gate (if network requires it) + +│ │ └── RoleSelectionView.swift # Participant: tap to claim a node + +│ ├── Main/ + +│ │ ├── MainView.swift # Tab 1: Live feed + push-to-talk button + +│ │ ├── TreeView.swift # Tab 2: Visual tree hierarchy with live status + +│ │ ├── DataFlowView.swift # Tab 3: AI transparency view + +│ │ └── SettingsView.swift # Tab 4: Release role, edit tree (organiser only) + +│ └── Components/ + +│ ├── TreeNodeView.swift # Reusable node cell (name, claim status, indicator) + +│ └── PTTButton.swift # Push-to-talk button component + +└── Utilities/ + +├── MessageDeduplicator.swift # UUID-based seen-set for mesh flooding + +└── TreeHelpers.swift # Parent/sibling/children lookups + +``` + + + +### Key Swift Frameworks Used + + + +``` + +┌─────────────────────────────────────────────────────────────────┐ + +│ iOS FRAMEWORK MAP │ + +│ │ + +│ ┌──────────────┐ ┌──────────────┐ ┌────────────────────┐ │ + +│ │ SwiftUI │ │ AVFoundation │ │ Core Bluetooth │ │ + +│ │ (All UI) │ │ (Audio I/O) │ │ (BLE Mesh) │ │ + +│ └──────┬───────┘ └──────┬───────┘ └────────┬───────────┘ │ + +│ │ │ │ │ + +│ ▼ ▼ ▼ │ + +│ ┌──────────────────────────────────────────────────────────┐ │ + +│ │ Swift Concurrency (Actors) │ │ + +│ │ UI Actor Audio Actor BLE Actor AI Actor │ │ + +│ └──────────────────────────┬───────────────────────────────┘ │ + +│ │ │ + +│ ▼ │ + +│ ┌──────────────────┐ │ + +│ │ Cactus SDK │ │ + +│ │ (Gemma 4) │ │ + +│ └──────────────────┘ │ + +│ │ + +│ ┌──────────────┐ │ + +│ │ SwiftData │ │ + +│ │ (Storage + Search) │ │ + +│ └──────────────┘ │ + +└─────────────────────────────────────────────────────────────────┘ + +``` + + + +### BLE Implementation Detail (Core Bluetooth) + + + +Each phone runs **both** a `CBCentralManager` (scanner/client) and a `CBPeripheralManager` (advertiser/server) simultaneously: + + + +```swift + +// Simplified BLE service architecture + +let tacNetServiceUUID = CBUUID(string: TACNET-...) + + + +// GATT Characteristics: + +let broadcastCharUUID = CBUUID(...) // For BROADCAST messages + +let compactionCharUUID = CBUUID(...) // For COMPACTION messages + +let treeConfigCharUUID = CBUUID(...) // For initial tree sync + + + +// Each phone: + +// 1. Advertises as peripheral → other phones connect to it + +// 2. Scans as central → connects to nearby phones + +// 3. On message receive → check UUID dedup → re-broadcast to all peers + +// 4. App layer filters by tree role (MessageRouter.swift) + +``` + + + +### Cactus SDK Integration + + + +```swift + +// CompactionEngine.swift — uses real Cactus Swift API + +import Cactus + + + +actor CompactionEngine { + +private var context: OpaquePointer? // Cactus context + +private var messageQueue: [Message] = [] + + + +init(modelPath: String) async throws { + +// Initialize Cactus context with Gemma 4 E4B model + +// cactusInit returns an OpaquePointer context + +let params = cactusDefaultParams() + +context = cactusInit(modelPath, params) + +} + + + +// Step 1: Transcribe audio to text (native Gemma 4 E4B audio encoder) + +func transcribeAudio(audioPath: String) async -> String { + +// cactusTranscribe uses Gemma 4 E4B's native ~300M param audio conformer + +// No separate STT model needed — single model handles audio input + +return cactusTranscribe(context, audioPath) + +} + + + +// Step 2: Compact/summarize transcripts + +func queueMessage(_ msg: Message) async -> CompactionResult? { + +messageQueue.append(msg) + + + +guard shouldTriggerCompaction() else { return nil } + + + +let prompt = buildCompactionPrompt(from: messageQueue) + +// cactusComplete runs text generation on Gemma 4 E4B + +let summary = cactusComplete(context, prompt, 64) // maxTokens: 64 + + + +let result = CompactionResult( + +summary: summary, + +sourceIDs: messageQueue.map(\\.id) + +) + + + +messageQueue.removeAll() + +return result + +} + + + +private func shouldTriggerCompaction() -> Bool { + +messageQueue.count >= 3 // or time-based trigger + +} + + + +deinit { + +if let ctx = context { cactusFree(ctx) } + +} + +} + +``` + + + +--- + + + +## 13. Bluetooth Mesh Protocol + + + +``` + +┌──────────────────────────────────────────────────┐ + +│ BLE MESH PROTOCOL │ + +│ │ + +│ 1. DISCOVERY │ + +│ Phone scans for nearby TacNet devices │ + +│ Connects to all found peers │ + +│ │ + +│ 2. ENCRYPT_KEY_EXCHANGE │ + +│ After BLE connection: participant receives session key │ + +│ Key encrypted with PIN-derived key │ + +│ All messages AES-256 E2E encrypted │ + +│ │ + +│ 3. FLOODING │ + +│ On send: message broadcast to all peers │ + +│ On receive: if not seen before, re-broadcast │ + +│ Dedup via message UUID │ + +│ │ + +│ 4. AUTO_REPARENT │ +│ If parent disconnects (60s timeout): │ +│ - Children traverse upward to find nearest connected ancestor │ +│ - TREE_UPDATE broadcast with new parent_id │ +│ - Routing rules update automatically │ +│ │ +│ 5. TTL (Time-To-Live) │ + +│ Each message has TTL (default: 10) │ + +│ Decremented on each hop │ + +│ Prevents infinite loops │ + +│ │ + +│ 6. DELIVERY │ + +│ All phones receive all messages │ + +│ App layer filters by tree role │ + +│ │ + +│ Range per hop: ~30-100m (BLE 5.0) │ + +│ Effective range: hops x range │ + +└──────────────────────────────────────────────────┘ + +``` + + + +--- + + + +## 14. Hackathon Demo Scenario + + + +### Setup: 4 phones minimum + + + +``` + +Phone 0 (Commander) + +│ + +┌──────┴──────┐ + +│ │ + +Phone 1 Phone 2 + +(Alpha Lead) (Bravo Lead) + +│ + +Phone 3 + +(Alpha-1) + +``` + + + +### Demo Flow + + + +1. **Phone 3** (Alpha-1): Push to talk — Enemy spotted near building 4 + +2. **Phone 1** (Alpha Lead): Hears the message live (sibling/parent) + +3. **Phone 1** auto-compacts: *Alpha: Enemy contact near building 4* + +4. **Phone 0** (Commander): Sees compacted summary appear on screen + +5. **Phone 2** (Bravo Lead): Push to talk — Bravo in position, all clear + +6. **Phone 0**: Sees both summaries, Gemma 4 produces: *SITREP: Alpha reports contact bldg 4. Bravo in position, clear.* + + + +**Total demo time: ~2 minutes. Zero internet. Zero servers.** + + + +--- + + + +## 15. Why This Wins at the Hackathon + + + +| Criteria | TacNet | + +|---|---| + +| **Uses Cactus** | Core inference engine on every phone | + +| **Uses Gemma 4** | On-device voice-to-summary, the exact new capability | + +| **Voice-controlled** | Push-to-talk is the primary interaction | + +| **On-device** | Fully offline, no cloud dependency | + +| **Novel** | No one has done AI-compacted hierarchical comms over BLE mesh | + +| **Demo-able** | Works with 3-4 phones in a room, visually compelling | + +| **Real-world impact** | Military, disaster relief, construction, events | + + + +--- + + + +## 16. Extension Opportunities + + + +- **Priority escalation**: Gemma 4 detects urgency keywords and escalates directly to root, bypassing normal compaction timing + +- **Two-way summaries**: Commander sends orders downward, compacted/expanded at each level for appropriate detail + +- **Two-way summaries**: Commander sends orders downward, compacted and expanded at each level for appropriate detail at each tier +- **Offline-first sync**: When internet is available, sync all raw messages to cloud for after-action review +- **Multi-language**: Gemma 4 translates messages between nodes speaking different languages +- **Map view**: GPS coordinates auto-embedded in all messages — commander sees all node positions on a shared map (5th UI tab) + + + +--- + + + +## 17. Data Flow Diagram (Complete) + + + +``` + +┌─────────┐ + +│ ROOT │ + +│ Phone 0 │ + +└────┬────┘ + +│ + +┌──────────┼──────────┐ + +│ │ │ + +▼ ▼ ▼ + +┌─────────┐┌─────────┐┌─────────┐ + +│ L1 Node ││ L1 Node ││ L1 Node │ + +│ Phone 1 ││ Phone 2 ││ Phone 3 │ + +└────┬────┘└─────────┘└────┬────┘ + +│ │ + +┌────┼────┐ ┌────┼────┐ + +│ │ │ │ │ │ + +▼ ▼ ▼ ▼ ▼ ▼ + +P4 P5 P6 P7 P8 P9 + + + + +════════════════════════════════════════ + +BROADCAST (blue): Sibling ↔ Sibling + Child → Parent + +COMPACTION (purple): Parent collects → Gemma 4 summarizes → sends UP + +════════════════════════════════════════ + + + +P4 speaks ──►┬──► P5 hears (sibling) ─── BROADCAST + +├──► P6 hears (sibling) ─── BROADCAST + +└──► P1 hears (parent) ─── BROADCAST + +│ + +▼ + +P1 collects P4+P5+P6 msgs + +P1 runs Gemma 4 compaction + +P1 emits summary ─── COMPACTION + +│ + +▼ + +P0 collects P1+P2+P3 summaries + +P0 runs Gemma 4 compaction + +P0 displays top-level SITREP ─── COMPACTION + +``` + + + +--- + + + +*Built for the Cactus x Gemma 4 Hackathon at YC HQ* + +--- + +## 18. Design Decisions & Clarifications + +The following decisions were made to refine the spec: + +| Decision | Choice | +|---|---| +| **Minimum iOS** | iOS 16.0+ | SwiftData, modern Swift Concurrency, stable BLE mesh APIs required. | +| **Cactus SDK** | Real SDK — XCFramework built from source (86s build), Swift API via Cactus.swift | +| **Audio over BLE** | Audio is NEVER transmitted. Only transcript text crosses the mesh. No BLE audio profile. | +| **STT** | Native via Gemma 4 E4B audio encoder (~300M params). No Whisper, no Apple Speech. Two-step: transcribe first, then compact. | +| **Compaction latency** | 1-2s target — acceptable for tactical use, prioritizes accuracy over raw speed. Benchmarked: 30s audio end-to-end in 0.3s, 40 tok/s decode on Apple Silicon. | +| **Tree editor** | Full drag-and-drop UI — add/remove/reparent/reorder nodes visually. | +| **Message history** | Full persistence with search — supports after-action review. | +| **Role transfer** | Organiser can promote any claimed node to organiser mid-operation. | +| **Conflict resolution** | If two devices race to claim the same node, organiser device wins automatically. | +| **Dynamic reparenting** | If a parent goes offline, children automatically reparent to the nearest available ancestor. | +| **Encryption** | End-to-end encryption on all BLE messages using a pre-shared key established on network join. | +| **Location data** | GPS coordinates embedded automatically in all messages. | +| **Model delivery** | Download on first launch (6.7GB INT4) | +| **Model tier** | E4B on all devices for MVP simplicity (4.5B params, ~2.8GB VRAM) | +| **Scope** | Full spec, no cuts. 5 milestones. | +| **Testing** | XCTest for logic + manual device testing for BLE/AI | diff --git a/PIPELINE.md b/PIPELINE.md new file mode 100644 index 00000000..5657d54a --- /dev/null +++ b/PIPELINE.md @@ -0,0 +1,87 @@ +# TacNet Communication Pipeline + +## Architecture + +``` +Audio In → [STT] → Text → [LLM] → Text → [TTS] → Audio Out +``` + +## Stage 1: Speech-to-Text (STT) + +**Model:** Cactus-Compute/parakeet-ctc-0.6b (INT4, Apple NPU) + +- 600M params, ~300-400 MB +- 201 ms latency on 20s audio, RTF 0.01 (100x real-time) +- 9.3% WER +- English-only, optimized for on-device live transcription +- Runs through existing `cactus_transcribe()` FFI +- Small enough to bundle in-app or fast-download, enabling instant STT without waiting for full Gemma download + +## Stage 2: Text-to-Text (LLM Inference) + +**Model:** Cactus-Compute/gemma-4-E4B-it (INT4, Apple NPU) + +- ~4B params, ~6.4 GB runtime download from HuggingFace +- Handles summarization/compaction of tactical comms +- Runs through existing `cactus_complete()` FFI +- Already integrated via `CactusTacticalSummarizer` and `CompactionEngine` + +## Stage 3: Text-to-Speech (TTS) + +**Current choice:** Apple AVSpeechSynthesizer (Option 1) + +No TTS models exist in the Cactus Compute ecosystem. Three options evaluated: + +| Option | Approach | Pros | Cons | +|--------|----------|------|------| +| **1. AVSpeechSynthesizer** | Built-in iOS API | Zero download, zero dependencies, works offline, lowest integration effort | Robotic voice quality | +| 2. Cloud TTS API | ElevenLabs, Google, OpenAI | High quality, natural voices | Requires network, latency, cost, privacy concern for tactical comms | +| 3. On-device open-source TTS | Kokoro (~82M), Piper, OuteTTS | Natural voice, offline, private | Needs separate inference runtime (CoreML/ONNX), additional integration work | + +**Decision:** Starting with Option 1 (AVSpeechSynthesizer). Can revisit with Option 3 if voice quality is insufficient. Option 2 is a fallback but less ideal for offline tactical scenarios. + +## Setup: Parakeet Model Weights + +The Parakeet CTC 0.6B model weights must be bundled in the app. Download them once during development: + +```bash +# Download INT4 Apple NPU weights from HuggingFace +cd TacNet/Resources/ParakeetCTC/ +# Download the apple zip from: +# https://huggingface.co/Cactus-Compute/parakeet-ctc-0.6b/tree/main/weights +# Extract the zip contents into this directory +``` + +After extraction, the directory should contain the model weight files. Then in Xcode: +1. Add `ParakeetCTC` folder to the project as a **folder reference** (blue folder icon) +2. Ensure it appears in Build Phases → Copy Bundle Resources + +The weights are excluded from git via `.gitignore` (too large for version control). + +## Implementation Architecture + +``` +┌──────────────────────────────────────────────────────────────┐ +│ Model Handle Layer │ +│ │ +│ ModelHandleProviding (protocol) │ +│ ├── BundledModelInitializationService.parakeet → STT │ +│ └── CactusModelInitializationService.shared → LLM │ +└──────────────────────────────────────────────────────────────┘ + +┌──────────────┐ ┌───────────────────┐ ┌────────────────┐ +│ PTT Record │ │ Gemma Compaction │ │ TTS Playback │ +│ │ │ │ │ │ +│ AVAudioEngine│ │ CactusTactical- │ │ AVSpeech- │ +│ → Parakeet │ │ Summarizer │ │ Synthesizer │ +│ → transcript │ │ → summary │ │ → audio out │ +└──────┬───────┘ └────────┬──────────┘ └────────▲───────┘ + │ │ │ + ▼ ▼ │ +┌──────────────────────────────────────────────────────────────┐ +│ BLE Mesh (BluetoothMeshService) │ +│ broadcast(transcript) ←→ compaction(summary) │ +│ │ +│ Receive path: message → MainViewModel → TTS speaks aloud │ +└──────────────────────────────────────────────────────────────┘ +``` diff --git a/README.md b/README.md index 54e31c3e..6d0b46db 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,162 @@ -Logo - -## Context -- Cactus (YC S25) is a low-latency engine for mobile devices & wearables. -- Cactus runs locally on edge devices with hybrid routing of complex tasks to cloud models like Gemini. -- Google DeepMind just released Gemma 4, the first on-device model you can voice-prompt. -- Gemma 4 on Cactus is multimodal, supporting voice, vision, function calling, transcription and more! - -## Challenge -- All teams MUST build products that use Gemma 4 on Cactus. -- All products MUST leverage voice functionality in some way. -- All submissions MUST be working MVPs capable of venture backing. -- Winner takes all: Guaranteed YC Interview + GCP Credits. - -## Special Tracks -- Best On-Device Enterprise Agent (B2B): Highest commercial viability for offline tools. -- Ultimate Consumer Voice Experience (B2C): Best use of low-latency compute to create ultra-natural, instantaneous voice interaction. -- Deepest Technical Integration: Pushing the boundaries of the hardware/software stack (e.g., novel routing, multi-agent on-device setups, extreme power optimization). - -Prizes per special track: -- 1st Place: $2,000 in GCP credits -- 2nd Place: $1,000 in GCP credits -- 3rd Place: $500 in GCP credits - -## Judging -- **Rubric 1**: The relevnance and realness of the problem and appeal to enterprises and VCs. -- **Rubric 2**: Correcness & quality of the MVP and demo. - -## Setup (clone this repo and hollistically follow) -- Step 1: Fork this repo, clone to your Mac, open terminal. -- Step 2: `git clone https://github.com/cactus-compute/cactus` -- Step 3: `cd cactus && source ./setup && cd ..` (re-run in new terminal) -- Step 4: `cactus build --python` -- Step 5: `cactus download google/functiongemma-270m-it --reconvert` -- Step 6: Get cactus key from the [cactus website](https://cactuscompute.com/dashboard/api-keys) -- Sept 7: Run `cactus auth` and enter your token when prompted. -- Step 8: `pip install google-genai` (if using cloud fallback) -- Step 9: Obtain Gemini API key from [Google AI Studio](https://aistudio.google.com/api-keys) (if using cloud fallback) -- Step 10: `export GEMINI_API_KEY="your-key"` (if using cloud fallback) - -## Next steps -1. Read Cactus docs carefully: [Link](https://docs.cactuscompute.com/latest/) -2. Read Gemma 4 on Cactus walkthrough carefully: [Link](https://docs.cactuscompute.com/latest/blog/gemma4/) -3. Cactus & DeepMind team would be available on-site. \ No newline at end of file +# TacNet + +TacNet is a native iOS tactical mesh application. Every phone in the network runs +an on-device Gemma 4 E4B model via the Cactus XCFramework and participates in a +decentralized Bluetooth Low Energy (BLE) mesh. Leaf nodes push-to-talk, on-device +speech-to-text produces transcripts, and parent nodes automatically compact child +messages into summaries that propagate up a configurable command tree. The system +is designed to work fully offline, with no servers and no internet dependency. + +## Requirements + +- macOS on Apple Silicon (darwin 25.4.0 in this repo's dev environment). +- Xcode 26.4 at `/Applications/Xcode.app`; `xcode-select -p` must point at + Xcode.app, not CommandLineTools. +- Swift 5.9+. +- iOS deployment target: **18.6** (applies to `TacNet`, `TacNetTests`, and + `TacNetUITests` — bumped because the Cactus XCFramework requires it). +- Simulator target: **iPhone 17** Simulator with its iOS 26.4 (or latest) runtime + installed. +- Cactus SDK: vendored as a prebuilt XCFramework at + `Frameworks/cactus-ios.xcframework`. Do not modify the binary artifact. Real + model weights (Gemma 4 E4B INT4, ~6.7 GB) are not required for Simulator + builds — unit and UI tests use mocks. Real weights are needed only for + on-device inference during multi-phone hardware testing. + +## Project Layout + +- `TacNet/` — app sources (SwiftUI views, view models, services, utilities). +- `TacNetTests/` — XCTest unit and integration tests plus committed screenshots. +- `TacNetUITests/` — XCUITest Simulator walkthroughs. +- `TacNet.xcodeproj/` — Xcode project and shared `TacNet` scheme. +- `Frameworks/` — vendored `cactus-ios.xcframework`. +- `.factory/` — repo-local automation: `services.yaml` (canonical commands), + `library/` (environment + user-testing notes), `validation/` (per-mission + evidence), `skills/`, and `init.sh`. + +## Build and Run + +All canonical commands are defined in `.factory/services.yaml` and target the +iPhone 17 Simulator. Run from the repo root. + +```bash +# Build +xcodebuild build -project TacNet.xcodeproj -scheme TacNet \ + -destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' + +# Clean build +xcodebuild clean build -project TacNet.xcodeproj -scheme TacNet \ + -destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' + +# Clean only +xcodebuild clean -project TacNet.xcodeproj -scheme TacNet +``` + +The project's warning gate is the `warnings-only` pipeline in +`.factory/services.yaml`: it runs a clean build and filters for `: warning:` +lines while excluding the vendored `cactus.framework` (see "Known Constraints"). + +## Testing + +The baseline expectation on a clean build is **≥122 unit tests** in +`TacNetTests` and **≥12 UI tests** in `TacNetUITests`, with zero failures and +zero non-upstream warnings. + +```bash +# All tests +xcodebuild test -project TacNet.xcodeproj -scheme TacNet \ + -destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' + +# Unit tests only +xcodebuild test -project TacNet.xcodeproj -scheme TacNet \ + -destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' \ + -only-testing:TacNetTests + +# UI tests only +xcodebuild test -project TacNet.xcodeproj -scheme TacNet \ + -destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' \ + -only-testing:TacNetUITests + +# A specific test +xcodebuild test -project TacNet.xcodeproj -scheme TacNet \ + -destination 'platform=iOS Simulator,name=iPhone 17,OS=latest' \ + -only-testing:TacNetTests/TacNetTests/testTreeNodeRoundTripEncodingWithNestedChildren +``` + +### UI-test launch-argument hooks + +The app reads these arguments from `ProcessInfo` to provide deterministic UI-test +entry points (see `TacNet/Views/ContentView.swift` and +`TacNet/Services/BluetoothMeshService.swift`): + +- `--ui-test-skip-download` — bypass the model download gate. +- `--ui-test-route=` — mount a dedicated test host view (for example + `main-ptt`, `settings`, and other route identifiers handled in + `ContentView.swift`). +- `--ui-test-role=` — seed role-scoped UI hosts with + deterministic identities. +- `--ui-test-mesh-peers=` — seed `BluetoothMeshService` with `N` fake peers + (Simulator has no real BLE). +- `--ui-test-capture-logs` — enable an in-app log buffer that records PTT log + lines so UI tests can assert on them. +- `--ui-test-download-fixture=` — swap the real download flow for a named + fixture. + +## Key Subsystems + +- **Bluetooth mesh** — `TacNet/Services/BluetoothMeshService.swift` implements + the dual Core Bluetooth central + peripheral stack, UUID-based deduplication, + and test-only peer seeding. Simulator cannot exercise real BLE; unit tests + use a `BluetoothMeshTransporting` mock. +- **Model download bootstrap** — `ModelDownloadService` (actor) and supporting + types live in `TacNet/Services/Cactus.swift`. The bootstrap flow is driven by + `AppBootstrapViewModel` in `TacNet/Views/ContentView.swift`, which gates + tactical features behind successful model readiness. +- **Push-to-Talk** — `PTTButton`, `PTTButtonStyle`, `PTTPressDispatcher`, and + `MainViewModel` all live in `TacNet/Views/ContentView.swift`. The dispatcher + owns press lifecycle and emits `[PTT]` log lines used by UI-test assertions. +- **Settings and roles** — role-scoped settings UI and organiser/participant + behaviours are driven from `ContentView.swift` via the `--ui-test-role` and + `--ui-test-route=settings` hooks. + +## Logging Conventions + +Runtime logs use `NSLog` with these prefixes so they can be filtered with +`xcrun simctl launch --console-pty` or Console.app: + +- `[BLE]` — Bluetooth mesh discovery, connection, and transport events. +- `[PTT]` — push-to-talk gesture, dispatch, and state-machine transitions. +- `[ModelDownload]` — bootstrap download progress, retry, and readiness. +- `[MSG]` — message routing and envelope handling. +- `[Role]` — role claim, release, and organiser transfer events. + +Red-flag strings the console must not contain during smoke walkthroughs are +listed in `.factory/library/user-testing.md`. + +## Known Constraints + +- The vendored upstream Cactus XCFramework emits umbrella-header warnings + (framework includes that Xcode flags as non-modular in a framework module) + that cannot be fixed inside this repo. They are intentionally excluded from + the warning gate via the `warnings-only` step in `.factory/services.yaml`, + which pipes `xcodebuild` output through `grep -v 'cactus.framework'`. +- Simulator has no Bluetooth, real Cactus inference, or reliable + `AVAudioEngine`; hardware-only assertions are documented in + `MANUAL_TESTING.md` for future physical-device runs. +- `IPHONEOS_DEPLOYMENT_TARGET` was raised to 18.6 across all targets (app and + both test targets) because Cactus requires it. This is a deliberate, + already-landed decision — not a pending upgrade. + +## Contributing + +- Follow the conventions already in place: canonical commands live in + `.factory/services.yaml`, environment notes in `.factory/library/environment.md`, + and validation guidance in `.factory/library/user-testing.md`. +- Architectural decisions are logged in `DECISIONS.md`. Manual-testing + assertions for physical hardware are in `MANUAL_TESTING.md`. A high-level + product and protocol reference is in `Orchestrator.md`. +- Mission-specific boundaries, when a mission is active, are documented in an + `AGENTS.md` inside that mission's directory under + `.factory/validation//`. Respect those boundaries when one is + present. +- Do not push to the remote unless explicitly instructed. Commit locally only. diff --git a/SETUP_LOG.md b/SETUP_LOG.md new file mode 100644 index 00000000..e96f2b80 --- /dev/null +++ b/SETUP_LOG.md @@ -0,0 +1,127 @@ +# TacNet Setup Log + +## Date: 2026-04-15 + +--- + +## 1. Gemma 4 E4B Model Download + +### Problem: HuggingFace Download Failure +- Attempted to download `google/gemma-4-E4B-it` (14.89 GB, full precision BF16) via `huggingface_hub` / `huggingface-cli`. +- Download repeatedly reset to 0% at ~90% progress. +- **Root cause:** Bug in `hf-xet` protocol (v1.4.3). The xet CAS reconstruction endpoint returns `416 Range Not Satisfiable` errors. Even when the download internally "completes," `huggingface_hub` fails to rename the `.incomplete` file, so the next run starts from scratch. +- Confirmed via xet logs at `~/.cache/huggingface/xet/logs/` -- 8 separate download attempts, all hit the same 416 error. +- This is a known issue: https://github.com/huggingface/xet-core/issues/581 +- Workaround (if ever needed): `HF_HUB_DISABLE_XET=1` forces plain HTTP download with proper resume support. + +### Solution: Cactus CLI +Instead of downloading raw safetensors from HuggingFace, we use the **Cactus CLI** which: +1. Downloads pre-quantized INT4 weights from `Cactus-Compute/gemma-4-E4B-it` on HuggingFace (bypasses xet entirely) +2. Converts to Cactus's optimized `.weights` format with Apple NPU support +3. Is the same engine used in the iOS app at runtime + +### Installation Steps +```bash +# Install Cactus CLI +brew install cactus-compute/cactus/cactus + +# Download Gemma 4 E4B (auto-downloads, converts, quantizes to INT4) +cactus download google/gemma-4-E4B-it --precision INT4 + +# Test interactively +cactus run google/gemma-4-E4B-it +``` + +### Model Location +- **Path:** `/opt/homebrew/opt/cactus/libexec/weights/gemma-4-e4b-it/` +- **Size:** 6.7 GB (INT4 quantized) +- **Format:** Cactus `.weights` files (2088 files: LLM layers, audio conformer, vision encoder with `.mlpackage` for Apple NPU) +- **Library:** `/opt/homebrew/opt/cactus/lib/libcactus.dylib` (3.7 MB) + +### Model Specs (Gemma 4 E4B) +| Property | Value | +|---|---| +| Effective Parameters | 4.5B (8B with embeddings) | +| Layers | 42 | +| Context Length | 128K tokens | +| Modalities | Text, Image, Audio | +| Audio Encoder | ~300M params (native, not bolt-on STT) | +| Vision Encoder | ~150M params | +| VRAM at INT4 | ~2.8 GB | +| Target Hardware | iPhone 15/16 (8 GB RAM) | + +### Obsolete Files (safe to delete) +- `/Users/yifuzuo/Desktop/yifu/startup/projects/hackathon/download_model.py` -- HuggingFace download script, no longer needed +- `/Users/yifuzuo/Desktop/yifu/startup/projects/hackathon/gemma-4-E4B-it/` -- 15 GB raw safetensors, not used by Cactus + +--- + +## 2. Cactus SDK for iOS + +### Build Command +```bash +# PREREQUISITE: Xcode must be the active developer toolchain (not just CommandLineTools) +sudo xcode-select -s /Applications/Xcode.app/Contents/Developer + +# Build from the cloned source repo (Homebrew install doesn't include build scripts) +cd /Users/yifuzuo/Desktop/yifu/startup/projects/hackathon/cactus +bash apple/build.sh # Produces static libs + XCFrameworks for iOS/macOS +``` + +### Build Fix Applied +The CMakeLists.txt referenced `kernel_sve2.cpp` which doesn't exist in the repo yet (WIP feature). +Fixed by setting `ENABLE_SVE2` to `"OFF"` in `apple/CmakeLists.txt` line 252. + +### Build Output (86 seconds, all successful) +| Artifact | Path | +|---|---| +| **iOS Device static lib** | `cactus/apple/libcactus-device.a` | +| **iOS Simulator static lib** | `cactus/apple/libcactus-simulator.a` | +| **iOS XCFramework** | `cactus/apple/cactus-ios.xcframework` | +| **macOS XCFramework** | `cactus/apple/cactus-macos.xcframework` | + +Warnings during build are all deployment-target availability warnings (iOS 13.0 vs 16.0 APIs) -- harmless since TacNet targets iOS 16.0+. + +### Cactus Source Repo +- **Path:** `/Users/yifuzuo/Desktop/yifu/startup/projects/hackathon/cactus/` +- **Branch:** `main` (commit `1e1a300` -- "Made non-thinking default for gemma4 & simultaneous multimodality") +- **Apple SDK files:** `cactus/apple/` contains `build.sh`, `Cactus.swift`, `module.modulemap` +- **Swift bindings:** `cactus/apple/Cactus.swift` -- the Swift wrapper for the C FFI + +### iOS Integration +- SDK: `cactus-apple` XCFramework with NPU support +- Swift API: `CactusModel.load()` -> `model.generate(prompt:)` +- Weights bundled in app or downloaded on first launch +- Docs: https://docs.cactuscompute.com/v1.12/ +- GitHub: https://github.com/cactus-compute/cactus (v1.13, /apple directory) + +### Key Cactus Commands +```bash +cactus run # Interactive chat playground +cactus transcribe # Live mic transcription +cactus download # Download model weights +cactus build --apple # Build static lib for iOS/macOS +cactus build --android # Build for Android +cactus test --ios # Run tests on connected iPhone +``` + +--- + +## 3. Architecture Decision: Model Selection + +Per Gemini analysis and Cactus docs: +- **Leaf nodes (squad members):** Gemma 4 E2B (2.3B params, ~1.4 GB VRAM, faster) +- **Intermediate/root nodes (squad leads, commander):** Gemma 4 E4B (4.5B params, ~2.8 GB VRAM, better summarization) +- Both E2B and E4B have **native audio input** (no separate STT needed) +- 26B and 31B models do NOT have audio and do NOT fit on iPhone + +--- + +## 4. Performance Benchmarks (from Cactus docs) + +| Metric (Apple Silicon) | E2B | +|---|---| +| 4096-token prefill | 660 tok/s | +| 1024-token decode | 40 tok/s | +| 30s audio end-to-end | 0.3s | +| Image encode (ANE) | 0.7s | diff --git a/TacNet.xcodeproj/project.pbxproj b/TacNet.xcodeproj/project.pbxproj new file mode 100644 index 00000000..2a43d6da --- /dev/null +++ b/TacNet.xcodeproj/project.pbxproj @@ -0,0 +1,736 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1DCCC616FCABDDA833533698 /* CactusFunctionProbe.swift in Sources */ = {isa = PBXBuildFile; fileRef = B523C85320FDF451CB0C1868 /* CactusFunctionProbe.swift */; }; + 22811F4EAE3A4C3BA68A7A21 /* DataModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5E9431F9B30497E8EDB9430 /* DataModels.swift */; }; + 3648F7E613F44A65D04613DE /* CoreBluetooth.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8451F9C07E853485C45F4F99 /* CoreBluetooth.framework */; }; + 5863C0BC2A92CD99DDF194B0 /* SwiftData.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4CE9717028B011FFC9077618 /* SwiftData.framework */; }; + 60F2A5ED16BD700076B0137A /* cactus-ios.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 840C37A2D2F49CB246A0AED1 /* cactus-ios.xcframework */; }; + 66E3031B06D5D288CAA152B8 /* TacNetUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A05AC0E84E210EDC684F6E0B /* TacNetUITests.swift */; }; + 6CDA0F05E8151523EE2CE29F /* Cactus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 942FCEBF9EBF2DBA591798EC /* Cactus.swift */; }; + 72FB70D9CC71AE84C1FAC19B /* TacNetApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 477E1CE86B91D0FB9556A590 /* TacNetApp.swift */; }; + 755B2109433A1DE815E1A11F /* AVFoundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8CB01F5E99AEAA1D250F4F26 /* AVFoundation.framework */; }; + 8325E27D29419CA6CD6908C5 /* CoreLocation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 80024568731CDCB04A01CE89 /* CoreLocation.framework */; }; + A1B2C3D4E5F60718293A4B5C /* BluetoothMeshService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4C3B2A1F0E9D8C7B6A59483 /* BluetoothMeshService.swift */; }; + A33F68E2F2BA987E1B5E21C3 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BC80363B64F4B07E7F424CCD /* Foundation.framework */; }; + AB428A6D50CBEC9E6967DB77 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BC80363B64F4B07E7F424CCD /* Foundation.framework */; }; + B3EDC1A65C8C85021FFD98C7 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BC80363B64F4B07E7F424CCD /* Foundation.framework */; }; + C15F1E37C9232822B5AD9CB0 /* FrameworkImportsProbe.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBA28F70F5CA30B49BA85E15 /* FrameworkImportsProbe.swift */; }; + CC3B531DE3DD2D794CC8CCDE /* TacNetTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF5403C7E7D4A6DEFDECBC54 /* TacNetTests.swift */; }; + D24C38D42F927CB700DC5610 /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = D24C38D32F927CB700DC5610 /* ZIPFoundation */; }; + DCDED2D71C5E58146B3EF85E /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 665084993F5AD346766676E3 /* ContentView.swift */; }; + E860E4FE8175156FBDA6ADB3 /* cactus-ios.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 840C37A2D2F49CB246A0AED1 /* cactus-ios.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + F1A2B3C4D5E6F708192A3B4C /* TextToSpeechService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A2B3C4D5E6F708192A3B40 /* TextToSpeechService.swift */; }; + F1A2B3C4D5E6F708192A3B4D /* ParakeetCTC in Resources */ = {isa = PBXBuildFile; fileRef = F1A2B3C4D5E6F708192A3B41 /* ParakeetCTC */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 97542E2B20426CB71CB4B186 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = B103C177B26E6D3CEBF09C80 /* Project object */; + proxyType = 1; + remoteGlobalIDString = DE86492B726262CB9760FC3C; + remoteInfo = TacNet; + }; + B31A43C0D255489E0820AB7C /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = B103C177B26E6D3CEBF09C80 /* Project object */; + proxyType = 1; + remoteGlobalIDString = DE86492B726262CB9760FC3C; + remoteInfo = TacNet; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 834E6DF35EB0CA6BC901B89F /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + E860E4FE8175156FBDA6ADB3 /* cactus-ios.xcframework in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1B33754BBFB094E4ADF6DC74 /* TacNet.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TacNet.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 1EAAC907BEADE3F8934CF872 /* TacNetTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TacNetTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 477E1CE86B91D0FB9556A590 /* TacNetApp.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TacNetApp.swift; sourceTree = ""; }; + 4CE9717028B011FFC9077618 /* SwiftData.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftData.framework; path = System/Library/Frameworks/SwiftData.framework; sourceTree = ""; }; + 653FB6F1AC35BB4057186A0B /* TacNetUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TacNetUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 665084993F5AD346766676E3 /* ContentView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + 80024568731CDCB04A01CE89 /* CoreLocation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreLocation.framework; path = System/Library/Frameworks/CoreLocation.framework; sourceTree = ""; }; + 840C37A2D2F49CB246A0AED1 /* cactus-ios.xcframework */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = wrapper.xcframework; path = "cactus-ios.xcframework"; sourceTree = ""; }; + 8451F9C07E853485C45F4F99 /* CoreBluetooth.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreBluetooth.framework; path = System/Library/Frameworks/CoreBluetooth.framework; sourceTree = ""; }; + 8CB01F5E99AEAA1D250F4F26 /* AVFoundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AVFoundation.framework; path = System/Library/Frameworks/AVFoundation.framework; sourceTree = ""; }; + 942FCEBF9EBF2DBA591798EC /* Cactus.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = Cactus.swift; sourceTree = ""; }; + A05AC0E84E210EDC684F6E0B /* TacNetUITests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TacNetUITests.swift; sourceTree = ""; }; + AF5403C7E7D4A6DEFDECBC54 /* TacNetTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TacNetTests.swift; sourceTree = ""; }; + B523C85320FDF451CB0C1868 /* CactusFunctionProbe.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CactusFunctionProbe.swift; sourceTree = ""; }; + BC80363B64F4B07E7F424CCD /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; + C5E9431F9B30497E8EDB9430 /* DataModels.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DataModels.swift; sourceTree = ""; }; + CBA28F70F5CA30B49BA85E15 /* FrameworkImportsProbe.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FrameworkImportsProbe.swift; sourceTree = ""; }; + D4C3B2A1F0E9D8C7B6A59483 /* BluetoothMeshService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BluetoothMeshService.swift; sourceTree = ""; }; + DCC08F62BEAD6B96F2F1A649 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + F1A2B3C4D5E6F708192A3B40 /* TextToSpeechService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TextToSpeechService.swift; sourceTree = ""; }; + F1A2B3C4D5E6F708192A3B41 /* ParakeetCTC */ = {isa = PBXFileReference; lastKnownFileType = folder; path = ParakeetCTC; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 11DFA7F0439BF27C647196D4 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + AB428A6D50CBEC9E6967DB77 /* Foundation.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 43CE07207CBC5985A6B0FABF /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + A33F68E2F2BA987E1B5E21C3 /* Foundation.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 6107D29D5B98D9861ADBA5C6 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + B3EDC1A65C8C85021FFD98C7 /* Foundation.framework in Frameworks */, + 3648F7E613F44A65D04613DE /* CoreBluetooth.framework in Frameworks */, + 755B2109433A1DE815E1A11F /* AVFoundation.framework in Frameworks */, + 8325E27D29419CA6CD6908C5 /* CoreLocation.framework in Frameworks */, + 5863C0BC2A92CD99DDF194B0 /* SwiftData.framework in Frameworks */, + 60F2A5ED16BD700076B0137A /* cactus-ios.xcframework in Frameworks */, + D24C38D42F927CB700DC5610 /* ZIPFoundation in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 02F396F777375CDA52C06045 /* Products */ = { + isa = PBXGroup; + children = ( + 1B33754BBFB094E4ADF6DC74 /* TacNet.app */, + 1EAAC907BEADE3F8934CF872 /* TacNetTests.xctest */, + 653FB6F1AC35BB4057186A0B /* TacNetUITests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 1AAA812917449A8F3B4F1DAF /* Resources */ = { + isa = PBXGroup; + children = ( + DCC08F62BEAD6B96F2F1A649 /* Info.plist */, + F1A2B3C4D5E6F708192A3B41 /* ParakeetCTC */, + ); + path = Resources; + sourceTree = ""; + }; + 36018B067800C561E8C4E6F3 = { + isa = PBXGroup; + children = ( + 02F396F777375CDA52C06045 /* Products */, + C9B7656BBCB5BB96F4ED2012 /* Frameworks */, + 8570BB712B76C2CAABCB5891 /* TacNet */, + 99471275C3EF589B8DD83435 /* TacNetTests */, + B4349ED85C6D5A880ACA68E0 /* Frameworks */, + 76AFA663436C88625AA1F550 /* TacNetUITests */, + ); + sourceTree = ""; + }; + 544AA3EB9D009B7AF0BA9FE9 /* Utilities */ = { + isa = PBXGroup; + children = ( + CBA28F70F5CA30B49BA85E15 /* FrameworkImportsProbe.swift */, + B523C85320FDF451CB0C1868 /* CactusFunctionProbe.swift */, + ); + path = Utilities; + sourceTree = ""; + }; + 5E9056FF843ACE375D2228D7 /* Views */ = { + isa = PBXGroup; + children = ( + 665084993F5AD346766676E3 /* ContentView.swift */, + ); + path = Views; + sourceTree = ""; + }; + 76AFA663436C88625AA1F550 /* TacNetUITests */ = { + isa = PBXGroup; + children = ( + A05AC0E84E210EDC684F6E0B /* TacNetUITests.swift */, + ); + path = TacNetUITests; + sourceTree = ""; + }; + 7D354B9B9D28EABA37BB4204 /* Services */ = { + isa = PBXGroup; + children = ( + 942FCEBF9EBF2DBA591798EC /* Cactus.swift */, + D4C3B2A1F0E9D8C7B6A59483 /* BluetoothMeshService.swift */, + F1A2B3C4D5E6F708192A3B40 /* TextToSpeechService.swift */, + ); + path = Services; + sourceTree = ""; + }; + 8570BB712B76C2CAABCB5891 /* TacNet */ = { + isa = PBXGroup; + children = ( + 86BCBDC367E58B2053A70B92 /* Models */, + 7D354B9B9D28EABA37BB4204 /* Services */, + A2703F24FCE114DE52FB3360 /* ViewModels */, + 5E9056FF843ACE375D2228D7 /* Views */, + 544AA3EB9D009B7AF0BA9FE9 /* Utilities */, + 1AAA812917449A8F3B4F1DAF /* Resources */, + 477E1CE86B91D0FB9556A590 /* TacNetApp.swift */, + ); + path = TacNet; + sourceTree = ""; + }; + 86BCBDC367E58B2053A70B92 /* Models */ = { + isa = PBXGroup; + children = ( + C5E9431F9B30497E8EDB9430 /* DataModels.swift */, + ); + path = Models; + sourceTree = ""; + }; + 99471275C3EF589B8DD83435 /* TacNetTests */ = { + isa = PBXGroup; + children = ( + AF5403C7E7D4A6DEFDECBC54 /* TacNetTests.swift */, + E2470CE6E0019A05EA6D15F9 /* Screenshots */, + ); + path = TacNetTests; + sourceTree = ""; + }; + A2703F24FCE114DE52FB3360 /* ViewModels */ = { + isa = PBXGroup; + children = ( + ); + path = ViewModels; + sourceTree = ""; + }; + B4349ED85C6D5A880ACA68E0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 840C37A2D2F49CB246A0AED1 /* cactus-ios.xcframework */, + ); + path = Frameworks; + sourceTree = ""; + }; + C7468689D164ADE8EF2CF706 /* iOS */ = { + isa = PBXGroup; + children = ( + BC80363B64F4B07E7F424CCD /* Foundation.framework */, + ); + name = iOS; + sourceTree = ""; + }; + C9B7656BBCB5BB96F4ED2012 /* Frameworks */ = { + isa = PBXGroup; + children = ( + C7468689D164ADE8EF2CF706 /* iOS */, + 8451F9C07E853485C45F4F99 /* CoreBluetooth.framework */, + 8CB01F5E99AEAA1D250F4F26 /* AVFoundation.framework */, + 80024568731CDCB04A01CE89 /* CoreLocation.framework */, + 4CE9717028B011FFC9077618 /* SwiftData.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + E2470CE6E0019A05EA6D15F9 /* Screenshots */ = { + isa = PBXGroup; + children = ( + ); + path = Screenshots; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 392689135928FC994FC900BF /* TacNetTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 3DBDC05F955C0C6C1F29A543 /* Build configuration list for PBXNativeTarget "TacNetTests" */; + buildPhases = ( + BEF32D186FB60C99B9A0CF72 /* Sources */, + 43CE07207CBC5985A6B0FABF /* Frameworks */, + 26F2B7CF15C2A80F371DCEEE /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + BBDB1B48A3474AE53CF62684 /* PBXTargetDependency */, + ); + name = TacNetTests; + productName = TacNetTests; + productReference = 1EAAC907BEADE3F8934CF872 /* TacNetTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 614EC075E96D15F863A493F8 /* TacNetUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 268BC3949662904F0003975A /* Build configuration list for PBXNativeTarget "TacNetUITests" */; + buildPhases = ( + 4D277C1179BB185A4F91BD07 /* Sources */, + 11DFA7F0439BF27C647196D4 /* Frameworks */, + 968F2697A7A29280002E024D /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 753FB64AC3A7EB2E55F907DB /* PBXTargetDependency */, + ); + name = TacNetUITests; + productName = TacNetUITests; + productReference = 653FB6F1AC35BB4057186A0B /* TacNetUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; + DE86492B726262CB9760FC3C /* TacNet */ = { + isa = PBXNativeTarget; + buildConfigurationList = 7D33B37773CF641247EEB801 /* Build configuration list for PBXNativeTarget "TacNet" */; + buildPhases = ( + 72F8A90E2AE10CEF0CBEF333 /* Sources */, + 6107D29D5B98D9861ADBA5C6 /* Frameworks */, + 1EC639204C617B64C143CBC0 /* Resources */, + 834E6DF35EB0CA6BC901B89F /* Embed Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = TacNet; + productName = TacNet; + productReference = 1B33754BBFB094E4ADF6DC74 /* TacNet.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + B103C177B26E6D3CEBF09C80 /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 1600; + LastUpgradeCheck = 1600; + }; + buildConfigurationList = 154F9388501B268EC9E695F4 /* Build configuration list for PBXProject "TacNet" */; + compatibilityVersion = "Xcode 3.2"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 36018B067800C561E8C4E6F3; + packageReferences = ( + D24C38D22F927CB700DC5610 /* XCRemoteSwiftPackageReference "ZipFoundation" */, + ); + productRefGroup = 02F396F777375CDA52C06045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + DE86492B726262CB9760FC3C /* TacNet */, + 392689135928FC994FC900BF /* TacNetTests */, + 614EC075E96D15F863A493F8 /* TacNetUITests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 1EC639204C617B64C143CBC0 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + F1A2B3C4D5E6F708192A3B4D /* ParakeetCTC in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 26F2B7CF15C2A80F371DCEEE /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 968F2697A7A29280002E024D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 4D277C1179BB185A4F91BD07 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 66E3031B06D5D288CAA152B8 /* TacNetUITests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 72F8A90E2AE10CEF0CBEF333 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 72FB70D9CC71AE84C1FAC19B /* TacNetApp.swift in Sources */, + DCDED2D71C5E58146B3EF85E /* ContentView.swift in Sources */, + 6CDA0F05E8151523EE2CE29F /* Cactus.swift in Sources */, + A1B2C3D4E5F60718293A4B5C /* BluetoothMeshService.swift in Sources */, + C15F1E37C9232822B5AD9CB0 /* FrameworkImportsProbe.swift in Sources */, + 1DCCC616FCABDDA833533698 /* CactusFunctionProbe.swift in Sources */, + 22811F4EAE3A4C3BA68A7A21 /* DataModels.swift in Sources */, + F1A2B3C4D5E6F708192A3B4C /* TextToSpeechService.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + BEF32D186FB60C99B9A0CF72 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + CC3B531DE3DD2D794CC8CCDE /* TacNetTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 753FB64AC3A7EB2E55F907DB /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = TacNet; + target = DE86492B726262CB9760FC3C /* TacNet */; + targetProxy = B31A43C0D255489E0820AB7C /* PBXContainerItemProxy */; + }; + BBDB1B48A3474AE53CF62684 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = TacNet; + target = DE86492B726262CB9760FC3C /* TacNet */; + targetProxy = 97542E2B20426CB71CB4B186 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 293191A73553C632A1DA89ED /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_ENABLE_OBJC_WEAK = NO; + DEVELOPMENT_TEAM = 9L92JZM53P; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = TacNet/Resources/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 18.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.tacnet.app; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 29966A4B27A0D7942CE75AD5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_ENABLE_OBJC_WEAK = NO; + DEVELOPMENT_TEAM = 9L92JZM53P; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = TacNet/Resources/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 18.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.tacnet.app; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 4AA52F24E493CC62189DAC79 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.6; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 7760635942997C4A8A1F3D4D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.6; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 89775759AE1CB71597B4123D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = 9L92JZM53P; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.tacnet.appUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = TacNet; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + CFB836C893F5898CB09668E1 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = 9L92JZM53P; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.tacnet.appUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = TacNet; + }; + name = Debug; + }; + D0022D74BD7A2CE478776C14 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CLANG_ENABLE_OBJC_WEAK = NO; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@loader_path/Frameworks", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.tacnet.appTests; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/TacNet.app/TacNet"; + }; + name = Debug; + }; + F375623228F5C0A06053DBE3 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CLANG_ENABLE_OBJC_WEAK = NO; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@loader_path/Frameworks", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.tacnet.appTests; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/TacNet.app/TacNet"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 154F9388501B268EC9E695F4 /* Build configuration list for PBXProject "TacNet" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 4AA52F24E493CC62189DAC79 /* Debug */, + 7760635942997C4A8A1F3D4D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 268BC3949662904F0003975A /* Build configuration list for PBXNativeTarget "TacNetUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 89775759AE1CB71597B4123D /* Release */, + CFB836C893F5898CB09668E1 /* Debug */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 3DBDC05F955C0C6C1F29A543 /* Build configuration list for PBXNativeTarget "TacNetTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F375623228F5C0A06053DBE3 /* Release */, + D0022D74BD7A2CE478776C14 /* Debug */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 7D33B37773CF641247EEB801 /* Build configuration list for PBXNativeTarget "TacNet" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 293191A73553C632A1DA89ED /* Release */, + 29966A4B27A0D7942CE75AD5 /* Debug */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + D24C38D22F927CB700DC5610 /* XCRemoteSwiftPackageReference "ZipFoundation" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/weichsel/ZipFoundation"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.9.20; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + D24C38D32F927CB700DC5610 /* ZIPFoundation */ = { + isa = XCSwiftPackageProductDependency; + package = D24C38D22F927CB700DC5610 /* XCRemoteSwiftPackageReference "ZipFoundation" */; + productName = ZIPFoundation; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = B103C177B26E6D3CEBF09C80 /* Project object */; +} diff --git a/TacNet.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/TacNet.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/TacNet.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/TacNet.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/TacNet.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 00000000..0c67376e --- /dev/null +++ b/TacNet.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,5 @@ + + + + + diff --git a/TacNet.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/TacNet.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000..7563a204 --- /dev/null +++ b/TacNet.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "b50a212a5fc541f7062d099b804ba278f9229862e34a0719645a7fcbdda60026", + "pins" : [ + { + "identity" : "zipfoundation", + "kind" : "remoteSourceControl", + "location" : "https://github.com/weichsel/ZipFoundation", + "state" : { + "revision" : "22787ffb59de99e5dc1fbfe80b19c97a904ad48d", + "version" : "0.9.20" + } + } + ], + "version" : 3 +} diff --git a/TacNet.xcodeproj/project.xcworkspace/xcuserdata/lucaspfingstenplanells.xcuserdatad/UserInterfaceState.xcuserstate b/TacNet.xcodeproj/project.xcworkspace/xcuserdata/lucaspfingstenplanells.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 00000000..41e590cd Binary files /dev/null and b/TacNet.xcodeproj/project.xcworkspace/xcuserdata/lucaspfingstenplanells.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/TacNet.xcodeproj/project.xcworkspace/xcuserdata/lucaspfingstenplanells.xcuserdatad/WorkspaceSettings.xcsettings b/TacNet.xcodeproj/project.xcworkspace/xcuserdata/lucaspfingstenplanells.xcuserdatad/WorkspaceSettings.xcsettings new file mode 100644 index 00000000..723a561c --- /dev/null +++ b/TacNet.xcodeproj/project.xcworkspace/xcuserdata/lucaspfingstenplanells.xcuserdatad/WorkspaceSettings.xcsettings @@ -0,0 +1,16 @@ + + + + + BuildLocationStyle + UseAppPreferences + CompilationCachingSetting + Default + CustomBuildLocationType + RelativeToDerivedData + DerivedDataLocationStyle + Default + ShowSharedSchemesAutomaticallyEnabled + + + diff --git a/TacNet.xcodeproj/xcshareddata/xcschemes/TacNet.xcscheme b/TacNet.xcodeproj/xcshareddata/xcschemes/TacNet.xcscheme new file mode 100644 index 00000000..23eac325 --- /dev/null +++ b/TacNet.xcodeproj/xcshareddata/xcschemes/TacNet.xcscheme @@ -0,0 +1,121 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/TacNet/Models/DataModels.swift b/TacNet/Models/DataModels.swift new file mode 100644 index 00000000..64fb6612 --- /dev/null +++ b/TacNet/Models/DataModels.swift @@ -0,0 +1,1356 @@ +import Foundation +import Combine +import CryptoKit +import SwiftData + +struct TreeNode: Codable, Equatable, Sendable { + var id: String + var label: String + var claimedBy: String? + var children: [TreeNode] + + enum CodingKeys: String, CodingKey { + case id + case label + case claimedBy = "claimed_by" + case children + } +} + +struct NetworkConfig: Codable, Equatable, Sendable { + var networkName: String + var networkID: UUID + var createdBy: String + var pinHash: String? + var encryptedSessionKey: String? + var version: Int + var tree: TreeNode + + enum CodingKeys: String, CodingKey { + case networkName = "network_name" + case networkID = "network_id" + case createdBy = "created_by" + case pinHash = "pin_hash" + case encryptedSessionKey = "encrypted_session_key" + case version + case tree + } + + var requiresPIN: Bool { + pinHash != nil + } + + var openSlotCount: Int { + if tree.label.isEmpty, tree.claimedBy == nil, tree.children.isEmpty { + return 0 + } + return tree.openSlotCount + } + + static func hashPIN(_ pin: String?) -> String? { + guard let pin else { + return nil + } + + let trimmed = pin.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return nil + } + + let digest = SHA256.hash(data: Data(trimmed.utf8)) + return digest.map { String(format: "%02x", $0) }.joined() + } + + func isValidPIN(_ pin: String?) -> Bool { + if pinHash == nil { + return true + } + return NetworkConfig.hashPIN(pin) == pinHash + } + + mutating func applyMutation(_ mutateTree: (inout TreeNode) -> Void) { + mutateTree(&tree) + version += 1 + } + + @discardableResult + mutating func mergeIfNewer(_ incoming: NetworkConfig) -> Bool { + guard incoming.networkID == networkID else { + return false + } + guard incoming.version > version else { + return false + } + self = incoming + return true + } +} + +enum NetworkEncryptionError: Error, Equatable { + case invalidWrappedSessionKey + case decryptionFailed + case missingSessionKey + case unencryptedPayloadRejected +} + +protocol SecurityEventLogging: Sendable { + func log(_ message: String) +} + +struct NoOpSecurityEventLogger: SecurityEventLogging { + func log(_ message: String) {} +} + +final class NetworkEncryptionService: @unchecked Sendable { + private static let transportHeader = Data("TNENC1".utf8) + private static let wrappedSessionKeyPrefix = "key:v1:" + private static let storageTokenPrefix = "enc:v1:" + private static let storageKeyMaterial = "TacNet.AfterActionReview.Storage.v1" + + private let lock = NSLock() + private let logger: any SecurityEventLogging + private var activeNetworkID: UUID? + private var activeSessionKey: SymmetricKey? + + init(logger: any SecurityEventLogging = NoOpSecurityEventLogger()) { + self.logger = logger + } + + static func keyMaterial(pinHash: String?, networkID: UUID) -> String { + if let pinHash, !pinHash.isEmpty { + return "pin:\(pinHash.lowercased())" + } + return "network:\(networkID.uuidString.lowercased())" + } + + var hasActiveSessionKey: Bool { + withLock { activeSessionKey != nil } + } + + func hasSessionKey(for networkID: UUID) -> Bool { + withLock { + activeNetworkID == networkID && activeSessionKey != nil + } + } + + func makeWrappedSessionKey(networkID: UUID, keyMaterial: String) throws -> String { + let sessionKey = SymmetricKey(size: .bits256) + let wrappingKey = Self.derivedSymmetricKey(from: keyMaterial) + + do { + let sealed = try AES.GCM.seal(Self.sessionKeyData(sessionKey), using: wrappingKey) + guard let combined = sealed.combined else { + throw NetworkEncryptionError.invalidWrappedSessionKey + } + + activate(sessionKey: sessionKey, networkID: networkID) + logger.log("Encryption: generated wrapped session key for network \(networkID.uuidString).") + return Self.wrappedSessionKeyPrefix + combined.base64EncodedString() + } catch { + logger.log("Encryption: failed to wrap session key for network \(networkID.uuidString).") + throw NetworkEncryptionError.invalidWrappedSessionKey + } + } + + func activateSessionKey(networkID: UUID, wrappedSessionKey: String, keyMaterial: String) throws { + guard wrappedSessionKey.hasPrefix(Self.wrappedSessionKeyPrefix) else { + throw NetworkEncryptionError.invalidWrappedSessionKey + } + + let encodedCombined = String(wrappedSessionKey.dropFirst(Self.wrappedSessionKeyPrefix.count)) + guard let combined = Data(base64Encoded: encodedCombined) else { + throw NetworkEncryptionError.invalidWrappedSessionKey + } + + let wrappingKey = Self.derivedSymmetricKey(from: keyMaterial) + do { + let sealedBox = try AES.GCM.SealedBox(combined: combined) + let unwrappedSessionKeyData = try AES.GCM.open(sealedBox, using: wrappingKey) + guard unwrappedSessionKeyData.count == 32 else { + throw NetworkEncryptionError.invalidWrappedSessionKey + } + + activate( + sessionKey: SymmetricKey(data: unwrappedSessionKeyData), + networkID: networkID + ) + logger.log("Encryption: activated wrapped session key for network \(networkID.uuidString).") + } catch { + logger.log("Encryption: failed to activate wrapped session key for network \(networkID.uuidString).") + throw NetworkEncryptionError.decryptionFailed + } + } + + func activateDeterministicSessionKey(networkID: UUID, keyMaterial: String) { + let sessionKey = Self.derivedSymmetricKey(from: "session:\(keyMaterial)") + activate(sessionKey: sessionKey, networkID: networkID) + logger.log("Encryption: activated deterministic session key for network \(networkID.uuidString).") + } + + func clearSessionKey() { + withLock { + activeNetworkID = nil + activeSessionKey = nil + } + logger.log("Encryption: cleared active session key context.") + } + + func encryptTransportPayload(_ payload: Data) throws -> Data { + guard let sessionKey = withLock({ activeSessionKey }) else { + return payload + } + + let sealed = try AES.GCM.seal(payload, using: sessionKey) + guard let combined = sealed.combined else { + throw NetworkEncryptionError.decryptionFailed + } + + return Self.transportHeader + combined + } + + func decryptTransportPayload(_ payload: Data) throws -> Data { + let activeSessionKey = withLock { self.activeSessionKey } + + if payload.starts(with: Self.transportHeader) { + guard let activeSessionKey else { + throw NetworkEncryptionError.missingSessionKey + } + + let encryptedPayload = payload.dropFirst(Self.transportHeader.count) + do { + let sealedBox = try AES.GCM.SealedBox(combined: encryptedPayload) + return try AES.GCM.open(sealedBox, using: activeSessionKey) + } catch { + throw NetworkEncryptionError.decryptionFailed + } + } + + guard activeSessionKey == nil else { + throw NetworkEncryptionError.unencryptedPayloadRejected + } + return payload + } + + static func encryptForStorage(_ plaintext: String) -> String { + let plainData = Data(plaintext.utf8) + guard !plainData.isEmpty else { + return plaintext + } + + do { + let sealed = try AES.GCM.seal(plainData, using: storageSymmetricKey) + guard let combined = sealed.combined else { + return storageTokenPrefix + } + return storageTokenPrefix + combined.base64EncodedString() + } catch { + return storageTokenPrefix + } + } + + static func decryptFromStorage(_ storedValue: String) -> String { + guard storedValue.hasPrefix(storageTokenPrefix) else { + return storedValue + } + + let encodedCombined = String(storedValue.dropFirst(storageTokenPrefix.count)) + guard let combined = Data(base64Encoded: encodedCombined), + let sealedBox = try? AES.GCM.SealedBox(combined: combined), + let decryptedData = try? AES.GCM.open(sealedBox, using: storageSymmetricKey) else { + return "" + } + + return String(decoding: decryptedData, as: UTF8.self) + } + + private func activate(sessionKey: SymmetricKey, networkID: UUID) { + withLock { + activeNetworkID = networkID + activeSessionKey = sessionKey + } + } + + private func withLock(_ body: () -> T) -> T { + lock.lock() + defer { lock.unlock() } + return body() + } + + private static var storageSymmetricKey: SymmetricKey { + derivedSymmetricKey(from: storageKeyMaterial) + } + + private static func derivedSymmetricKey(from material: String) -> SymmetricKey { + let digest = SHA256.hash(data: Data(material.utf8)) + return SymmetricKey(data: Data(digest)) + } + + private static func sessionKeyData(_ key: SymmetricKey) -> Data { + key.withUnsafeBytes { Data($0) } + } +} + +extension TreeNode { + var openSlotCount: Int { + let selfOpen = claimedBy == nil ? 1 : 0 + let childOpen = children.reduce(0) { partial, child in + partial + child.openSlotCount + } + return selfOpen + childOpen + } +} + +struct Message: Codable, Equatable, Sendable { + enum MessageType: String, Codable, CaseIterable, Sendable { + case broadcast = "BROADCAST" + case compaction = "COMPACTION" + case claim = "CLAIM" + case release = "RELEASE" + case treeUpdate = "TREE_UPDATE" + case promote = "PROMOTE" + case claimRejected = "CLAIM_REJECTED" + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let rawValue = try container.decode(String.self) + guard let value = MessageType(rawValue: rawValue) else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Unsupported message type: \(rawValue)" + ) + } + self = value + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } + } + + struct Payload: Codable, Equatable, Sendable { + struct Location: Codable, Equatable, Sendable { + var lat: Double + var lon: Double + var accuracy: Double + var isFallback: Bool + + enum CodingKeys: String, CodingKey { + case lat + case lon + case accuracy + case isFallback = "is_fallback" + } + + static func fromCoordinates(latitude: Double?, longitude: Double?, accuracy: Double?) -> Location { + guard let latitude, let longitude, let accuracy else { + return Location(lat: 0, lon: 0, accuracy: -1, isFallback: true) + } + return Location(lat: latitude, lon: longitude, accuracy: accuracy, isFallback: false) + } + } + + var location: Location + var encrypted: Bool + var transcript: String? + var summary: String? + var claimedNodeID: String? + var targetNodeID: String? + var rejectionReason: String? + var tree: TreeNode? + var networkVersion: Int? + + enum CodingKeys: String, CodingKey { + case location + case encrypted + case transcript + case summary + case claimedNodeID = "claimed_node_id" + case targetNodeID = "target_node_id" + case rejectionReason = "rejection_reason" + case tree + case networkVersion = "network_version" + } + } + + var id: UUID + var type: MessageType + var senderID: String + var senderRole: String + var parentID: String? + var treeLevel: Int + var timestamp: Date + var ttl: Int + var payload: Payload + + enum CodingKeys: String, CodingKey { + case id + case type + case senderID = "sender_id" + case senderRole = "sender_role" + case parentID = "parent_id" + case treeLevel = "tree_level" + case timestamp + case ttl + case payload + } + + static func make( + id: UUID = UUID(), + type: MessageType, + senderID: String, + senderRole: String, + parentID: String?, + treeLevel: Int, + ttl: Int, + encrypted: Bool, + latitude: Double?, + longitude: Double?, + accuracy: Double?, + transcript: String? = nil, + summary: String? = nil, + claimedNodeID: String? = nil, + targetNodeID: String? = nil, + rejectionReason: String? = nil, + tree: TreeNode? = nil, + networkVersion: Int? = nil, + timestamp: Date = Date() + ) -> Message { + Message( + id: id, + type: type, + senderID: senderID, + senderRole: senderRole, + parentID: parentID, + treeLevel: treeLevel, + timestamp: timestamp, + ttl: ttl, + payload: Payload( + location: .fromCoordinates(latitude: latitude, longitude: longitude, accuracy: accuracy), + encrypted: encrypted, + transcript: transcript, + summary: summary, + claimedNodeID: claimedNodeID, + targetNodeID: targetNodeID, + rejectionReason: rejectionReason, + tree: tree, + networkVersion: networkVersion + ) + ) + } +} + +struct MessageRouter: Sendable { + struct GPSReading: Equatable, Sendable { + var latitude: Double? + var longitude: Double? + var accuracy: Double? + + static let unavailable = GPSReading(latitude: nil, longitude: nil, accuracy: nil) + } + + typealias GPSProvider = @Sendable () -> GPSReading + + private let defaultTTL: Int + private let gpsProvider: GPSProvider + + init( + defaultTTL: Int = 8, + gpsProvider: @escaping GPSProvider = { .unavailable } + ) { + self.defaultTTL = max(1, defaultTTL) + self.gpsProvider = gpsProvider + } + + func shouldDisplay(_ message: Message, for recipientNodeID: String, in tree: TreeNode) -> Bool { + switch message.type { + case .broadcast: + return broadcastRecipientNodeIDs(for: message, in: tree).contains(recipientNodeID) + case .compaction: + return compactionRecipientNodeIDs(for: message, in: tree).contains(recipientNodeID) + default: + return false + } + } + + func makeBroadcastMessage( + transcript: String, + senderID: String, + senderNodeID: String, + senderRole: String, + in tree: TreeNode, + ttl: Int? = nil, + encrypted: Bool = false, + timestamp: Date = Date() + ) -> Message { + let gps = gpsProvider() + return Message.make( + type: .broadcast, + senderID: senderID, + senderRole: senderRole, + parentID: TreeHelpers.parent(of: senderNodeID, in: tree)?.id, + treeLevel: TreeHelpers.level(of: senderNodeID, in: tree) ?? 0, + ttl: max(1, ttl ?? defaultTTL), + encrypted: encrypted, + latitude: gps.latitude, + longitude: gps.longitude, + accuracy: gps.accuracy, + transcript: transcript, + timestamp: timestamp + ) + } + + func makeCompactionMessage( + summary: String, + senderID: String, + senderNodeID: String, + senderRole: String, + in tree: TreeNode, + ttl: Int? = nil, + encrypted: Bool = false, + timestamp: Date = Date() + ) -> Message { + let gps = gpsProvider() + return Message.make( + type: .compaction, + senderID: senderID, + senderRole: senderRole, + parentID: TreeHelpers.parent(of: senderNodeID, in: tree)?.id, + treeLevel: TreeHelpers.level(of: senderNodeID, in: tree) ?? 0, + ttl: max(1, ttl ?? defaultTTL), + encrypted: encrypted, + latitude: gps.latitude, + longitude: gps.longitude, + accuracy: gps.accuracy, + summary: summary, + timestamp: timestamp + ) + } + + private func broadcastRecipientNodeIDs(for message: Message, in tree: TreeNode) -> Set { + guard let parentID = resolvedParentID(for: message, in: tree) else { + return [] + } + + let senderNodeID = resolveSenderNodeID(for: message, in: tree) + let siblingNodeIDs = TreeHelpers.children(of: parentID, in: tree) + .map(\.id) + .filter { $0 != senderNodeID } + + return Set(siblingNodeIDs + [parentID]) + } + + private func compactionRecipientNodeIDs(for message: Message, in tree: TreeNode) -> Set { + guard let parentID = resolvedParentID(for: message, in: tree) else { + return [] + } + return [parentID] + } + + private func resolvedParentID(for message: Message, in tree: TreeNode) -> String? { + if let senderNodeID = resolveSenderNodeID(for: message, in: tree) { + return TreeHelpers.parent(of: senderNodeID, in: tree)?.id + } + return message.parentID + } + + private func resolveSenderNodeID(for message: Message, in tree: TreeNode) -> String? { + if TreeHelpers.level(of: message.senderID, in: tree) != nil { + return message.senderID + } + + return findNodeID(claimedBy: message.senderID, in: tree) + } + + private func findNodeID(claimedBy deviceID: String, in tree: TreeNode) -> String? { + if tree.claimedBy == deviceID { + return tree.id + } + + for child in tree.children { + if let nodeID = findNodeID(claimedBy: deviceID, in: child) { + return nodeID + } + } + + return nil + } +} + +struct NodeIdentity: Codable, Equatable, Sendable { + var deviceID: String + var claimedNodeID: String? + var networkID: UUID + + enum CodingKeys: String, CodingKey { + case deviceID = "device_id" + case claimedNodeID = "claimed_node_id" + case networkID = "network_id" + } +} + +struct NodeIdentityStore { + private let defaults: UserDefaults + private let storageKey: String + + init(defaults: UserDefaults = .standard, storageKey: String = "TacNet.NodeIdentity") { + self.defaults = defaults + self.storageKey = storageKey + } + + func save(_ identity: NodeIdentity) throws { + let data = try JSONEncoder().encode(identity) + defaults.set(data, forKey: storageKey) + } + + func load() -> NodeIdentity? { + guard let data = defaults.data(forKey: storageKey) else { + return nil + } + return try? JSONDecoder().decode(NodeIdentity.self, from: data) + } + + func clear() { + defaults.removeObject(forKey: storageKey) + } +} + +struct NetworkConfigStore { + private let defaults: UserDefaults + private let storageKey: String + + init(defaults: UserDefaults = .standard, storageKey: String = "TacNet.NetworkConfig") { + self.defaults = defaults + self.storageKey = storageKey + } + + func save(_ config: NetworkConfig) throws { + let data = try JSONEncoder().encode(config) + defaults.set(data, forKey: storageKey) + } + + func load() -> NetworkConfig? { + guard let data = defaults.data(forKey: storageKey) else { + return nil + } + return try? JSONDecoder().decode(NetworkConfig.self, from: data) + } + + func clear() { + defaults.removeObject(forKey: storageKey) + } +} + +struct AfterActionReviewMessage: Identifiable, Equatable, Sendable { + let id: UUID + let senderID: String + let senderRole: String + let timestamp: Date + let type: Message.MessageType + let body: String + let latitude: Double + let longitude: Double + let accuracy: Double + let isFallbackLocation: Bool +} + +@MainActor +protocol AfterActionReviewPersisting: AnyObject { + func persist(_ message: Message) + func allMessages() -> [AfterActionReviewMessage] + func search(query: String) -> [AfterActionReviewMessage] + func purgeAll() +} + +@MainActor +final class InMemoryAfterActionReviewStore: AfterActionReviewPersisting { + private var recordsByID: [UUID: AfterActionReviewMessage] = [:] + + func persist(_ message: Message) { + guard let record = AfterActionReviewRecordFactory.make(from: message) else { + return + } + recordsByID[record.id] = record + } + + func allMessages() -> [AfterActionReviewMessage] { + recordsByID.values.sorted(by: Self.newestFirst) + } + + func search(query: String) -> [AfterActionReviewMessage] { + let normalizedQuery = Self.normalized(query) + let source = allMessages() + guard !normalizedQuery.isEmpty else { + return source + } + + return source.filter { record in + Self.searchableText(for: record) + .range(of: normalizedQuery, options: [.caseInsensitive, .diacriticInsensitive]) != nil + } + } + + func purgeAll() { + recordsByID.removeAll() + } + + private static func searchableText(for record: AfterActionReviewMessage) -> String { + "\(record.body)\n\(record.senderRole)\n\(record.senderID)\n\(record.type.rawValue)" + } + + private static func normalized(_ query: String) -> String { + query.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private static func newestFirst(_ lhs: AfterActionReviewMessage, _ rhs: AfterActionReviewMessage) -> Bool { + lhs.timestamp > rhs.timestamp + } +} + +@available(iOS 17.0, *) +@Model +final class PersistedAfterActionReviewMessage { + @Attribute(.unique) var id: UUID + var senderID: String + var senderRole: String + var timestamp: Date + var typeRawValue: String + var body: String + var latitude: Double + var longitude: Double + var accuracy: Double + var isFallbackLocation: Bool + + init(from record: AfterActionReviewMessage) { + id = record.id + senderID = record.senderID + senderRole = record.senderRole + timestamp = record.timestamp + typeRawValue = record.type.rawValue + body = NetworkEncryptionService.encryptForStorage(record.body) + latitude = record.latitude + longitude = record.longitude + accuracy = record.accuracy + isFallbackLocation = record.isFallbackLocation + } + + var asRecord: AfterActionReviewMessage { + let resolvedType = Message.MessageType(rawValue: typeRawValue) ?? .broadcast + return AfterActionReviewMessage( + id: id, + senderID: senderID, + senderRole: senderRole, + timestamp: timestamp, + type: resolvedType, + body: NetworkEncryptionService.decryptFromStorage(body), + latitude: latitude, + longitude: longitude, + accuracy: accuracy, + isFallbackLocation: isFallbackLocation + ) + } +} + +@available(iOS 17.0, *) +@MainActor +final class SwiftDataAfterActionReviewStore: AfterActionReviewPersisting { + private let modelContainer: ModelContainer + private let modelContext: ModelContext + + init(isStoredInMemoryOnly: Bool = false) throws { + let configuration = ModelConfiguration(isStoredInMemoryOnly: isStoredInMemoryOnly) + modelContainer = try ModelContainer( + for: PersistedAfterActionReviewMessage.self, + configurations: configuration + ) + modelContext = ModelContext(modelContainer) + } + + func persist(_ message: Message) { + guard let record = AfterActionReviewRecordFactory.make(from: message) else { + return + } + + let recordID = record.id + let existsDescriptor = FetchDescriptor( + predicate: #Predicate { stored in + stored.id == recordID + } + ) + if let existing = try? modelContext.fetch(existsDescriptor), + !existing.isEmpty { + return + } + + modelContext.insert(PersistedAfterActionReviewMessage(from: record)) + try? modelContext.save() + } + + func allMessages() -> [AfterActionReviewMessage] { + let descriptor = FetchDescriptor( + sortBy: [SortDescriptor(\.timestamp, order: .reverse)] + ) + let stored = (try? modelContext.fetch(descriptor)) ?? [] + return stored.map(\.asRecord) + } + + func search(query: String) -> [AfterActionReviewMessage] { + let normalizedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines) + let source = allMessages() + guard !normalizedQuery.isEmpty else { + return source + } + + return source.filter { record in + let searchable = "\(record.body)\n\(record.senderRole)\n\(record.senderID)\n\(record.type.rawValue)" + return searchable.range( + of: normalizedQuery, + options: [.caseInsensitive, .diacriticInsensitive] + ) != nil + } + } + + func purgeAll() { + let descriptor = FetchDescriptor() + guard let stored = try? modelContext.fetch(descriptor) else { + return + } + stored.forEach(modelContext.delete) + try? modelContext.save() + } +} + +private enum AfterActionReviewRecordFactory { + static func make(from message: Message) -> AfterActionReviewMessage? { + let body: String + + switch message.type { + case .broadcast: + body = message.payload.transcript?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + case .compaction: + body = message.payload.summary?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + default: + return nil + } + + return AfterActionReviewMessage( + id: message.id, + senderID: message.senderID, + senderRole: message.senderRole, + timestamp: message.timestamp, + type: message.type, + body: body, + latitude: message.payload.location.lat, + longitude: message.payload.location.lon, + accuracy: message.payload.location.accuracy, + isFallbackLocation: message.payload.location.isFallback + ) + } +} + +enum TreeHelpers { + static func parent(of nodeID: String, in tree: TreeNode) -> TreeNode? { + guard let path = path(to: nodeID, in: tree), path.count > 1 else { + return nil + } + return path[path.count - 2] + } + + static func siblings(of nodeID: String, in tree: TreeNode) -> [TreeNode] { + guard let parentNode = parent(of: nodeID, in: tree) else { + return [] + } + return parentNode.children.filter { $0.id != nodeID } + } + + static func children(of nodeID: String, in tree: TreeNode) -> [TreeNode] { + findNode(withID: nodeID, in: tree)?.children ?? [] + } + + static func level(of nodeID: String, in tree: TreeNode) -> Int? { + guard let path = path(to: nodeID, in: tree) else { + return nil + } + return path.count - 1 + } + + private static func path(to nodeID: String, in tree: TreeNode) -> [TreeNode]? { + if tree.id == nodeID { + return [tree] + } + + for child in tree.children { + if let childPath = path(to: nodeID, in: child) { + return [tree] + childPath + } + } + + return nil + } + + private static func findNode(withID nodeID: String, in tree: TreeNode) -> TreeNode? { + if tree.id == nodeID { + return tree + } + + for child in tree.children { + if let found = findNode(withID: nodeID, in: child) { + return found + } + } + + return nil + } +} + +final class MessageDeduplicator: @unchecked Sendable { + private let capacity: Int + private var seenSet: Set + private var ringBuffer: [UUID] + private var nextEvictionIndex: Int + private let lock = NSLock() + + init(capacity: Int = 50_000) { + self.capacity = max(1, capacity) + self.seenSet = Set(minimumCapacity: self.capacity) + self.ringBuffer = [] + self.ringBuffer.reserveCapacity(self.capacity) + self.nextEvictionIndex = 0 + } + + func isDuplicate(messageId: UUID) -> Bool { + lock.lock() + defer { lock.unlock() } + + if seenSet.contains(messageId) { + return true + } + + if ringBuffer.count < capacity { + ringBuffer.append(messageId) + } else { + let evicted = ringBuffer[nextEvictionIndex] + seenSet.remove(evicted) + ringBuffer[nextEvictionIndex] = messageId + nextEvictionIndex = (nextEvictionIndex + 1) % capacity + } + + seenSet.insert(messageId) + return false + } + + var trackedCount: Int { + lock.lock() + defer { lock.unlock() } + return seenSet.count + } +} + +final class TreeBuilderViewModel: ObservableObject, @unchecked Sendable { + @Published private(set) var networkConfig: NetworkConfig + @Published private(set) var versionHistory: [Int] + + private let lock = NSLock() + + init( + networkName: String = "New TacNet Network", + createdBy: String = "organiser-device", + pin: String? = nil, + rootNode: TreeNode = TreeNode(id: "root", label: "Commander", claimedBy: nil, children: []), + initialVersion: Int = 1, + networkID: UUID = UUID() + ) { + let normalizedName = Self.normalizedNetworkName(networkName) + let pinHash = Self.pinHash(from: pin) + let initialConfig = NetworkConfig( + networkName: normalizedName, + networkID: networkID, + createdBy: createdBy, + pinHash: pinHash, + version: max(1, initialVersion), + tree: rootNode + ) + + networkConfig = initialConfig + versionHistory = [initialConfig.version] + } + + var currentVersion: Int { + withLock { networkConfig.version } + } + + var isTreeEmpty: Bool { + withLock { + let tree = networkConfig.tree + return tree.children.isEmpty && tree.claimedBy == nil && tree.label.isEmpty + } + } + + func node(withID nodeID: String) -> TreeNode? { + withLock { + Self.findNode(withID: nodeID, in: networkConfig.tree) + } + } + + @discardableResult + func addNode(parentID: String?, label: String) -> TreeNode? { + let trimmedLabel = label.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedLabel.isEmpty else { + return nil + } + + return withLock { + var updatedConfig = networkConfig + let targetParentID = parentID ?? updatedConfig.tree.id + + guard let createdNode = Self.insertChild( + parentID: targetParentID, + label: trimmedLabel, + in: &updatedConfig.tree + ) else { + return nil + } + + commitMutation(updatedConfig) + return createdNode + } + } + + @discardableResult + func removeNode(nodeID: String) -> Bool { + withLock { + var updatedConfig = networkConfig + let didRemove: Bool + + if updatedConfig.tree.id == nodeID { + didRemove = true + updatedConfig.tree.claimedBy = nil + updatedConfig.tree.label = "" + updatedConfig.tree.children = [] + } else { + didRemove = Self.removeNode(nodeID: nodeID, from: &updatedConfig.tree) + } + + guard didRemove else { + return false + } + + commitMutation(updatedConfig) + return true + } + } + + @discardableResult + func renameNode(nodeID: String, newLabel: String) -> Bool { + withLock { + var updatedConfig = networkConfig + guard Self.renameNode(nodeID: nodeID, newLabel: newLabel, in: &updatedConfig.tree) else { + return false + } + commitMutation(updatedConfig) + return true + } + } + + @discardableResult + func moveNode(nodeID: String, newParentID: String) -> Bool { + withLock { + var updatedConfig = networkConfig + + guard nodeID != updatedConfig.tree.id, nodeID != newParentID else { + return false + } + + guard let nodeToMove = Self.findNode(withID: nodeID, in: updatedConfig.tree), + Self.findNode(withID: newParentID, in: updatedConfig.tree) != nil, + !Self.treeContainsNode(withID: newParentID, in: nodeToMove) else { + return false + } + + let originalParentID = TreeHelpers.parent(of: nodeID, in: updatedConfig.tree)?.id + guard let detachedNode = Self.detachNode(nodeID: nodeID, from: &updatedConfig.tree) else { + return false + } + + guard Self.appendChild(detachedNode, toParentID: newParentID, in: &updatedConfig.tree) else { + if let originalParentID { + _ = Self.appendChild(detachedNode, toParentID: originalParentID, in: &updatedConfig.tree) + } + return false + } + + commitMutation(updatedConfig) + return true + } + } + + @discardableResult + func reorderNode(nodeID: String, beforeSiblingID: String) -> Bool { + withLock { + guard nodeID != beforeSiblingID else { + return false + } + + var updatedConfig = networkConfig + + guard nodeID != updatedConfig.tree.id, + beforeSiblingID != updatedConfig.tree.id else { + return false + } + + guard let sourceParentID = TreeHelpers.parent(of: nodeID, in: updatedConfig.tree)?.id, + let targetParentID = TreeHelpers.parent(of: beforeSiblingID, in: updatedConfig.tree)?.id, + sourceParentID == targetParentID else { + return false + } + + guard Self.reorderNode(nodeID: nodeID, beforeSiblingID: beforeSiblingID, in: &updatedConfig.tree) else { + return false + } + + commitMutation(updatedConfig) + return true + } + } + + @discardableResult + func updateNetworkName(_ networkName: String) -> Bool { + withLock { + let normalized = Self.normalizedNetworkName(networkName) + guard normalized != networkConfig.networkName else { + return false + } + + var updatedConfig = networkConfig + updatedConfig.networkName = normalized + commitMutation(updatedConfig) + return true + } + } + + @discardableResult + func updatePin(_ pin: String?) -> Bool { + withLock { + let updatedHash = Self.pinHash(from: pin) + guard updatedHash != networkConfig.pinHash else { + return false + } + + var updatedConfig = networkConfig + updatedConfig.pinHash = updatedHash + commitMutation(updatedConfig) + return true + } + } + + @discardableResult + func clearTree() -> Bool { + withLock { + let tree = networkConfig.tree + guard !(tree.children.isEmpty && tree.claimedBy == nil && tree.label.isEmpty) else { + return false + } + + var updatedConfig = networkConfig + updatedConfig.tree.claimedBy = nil + updatedConfig.tree.label = "" + updatedConfig.tree.children = [] + commitMutation(updatedConfig) + return true + } + } + + func serializedTreeData(prettyPrinted: Bool = false) -> Data? { + withLock { + let encoder = Self.jsonEncoder(prettyPrinted: prettyPrinted) + return try? encoder.encode(networkConfig.tree) + } + } + + func serializedTreeJSON(prettyPrinted: Bool = false) -> String? { + guard let data = serializedTreeData(prettyPrinted: prettyPrinted) else { + return nil + } + return String(data: data, encoding: .utf8) + } + + func serializedNetworkConfigData(prettyPrinted: Bool = false) -> Data? { + withLock { + let encoder = Self.jsonEncoder(prettyPrinted: prettyPrinted) + return try? encoder.encode(networkConfig) + } + } + + func serializedNetworkConfigJSON(prettyPrinted: Bool = false) -> String? { + guard let data = serializedNetworkConfigData(prettyPrinted: prettyPrinted) else { + return nil + } + return String(data: data, encoding: .utf8) + } + + private func commitMutation(_ updatedConfig: NetworkConfig) { + var next = updatedConfig + next.version += 1 + networkConfig = next + versionHistory.append(next.version) + } + + private func withLock(_ body: () -> T) -> T { + lock.lock() + defer { lock.unlock() } + return body() + } + + private static func normalizedNetworkName(_ networkName: String) -> String { + let trimmed = networkName.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? "Untitled Network" : trimmed + } + + private static func pinHash(from pin: String?) -> String? { + NetworkConfig.hashPIN(pin) + } + + private static func jsonEncoder(prettyPrinted: Bool) -> JSONEncoder { + let encoder = JSONEncoder() + var formatting: JSONEncoder.OutputFormatting = [.sortedKeys] + if prettyPrinted { + formatting.insert(.prettyPrinted) + } + encoder.outputFormatting = formatting + return encoder + } + + private static func findNode(withID nodeID: String, in tree: TreeNode) -> TreeNode? { + if tree.id == nodeID { + return tree + } + + for child in tree.children { + if let found = findNode(withID: nodeID, in: child) { + return found + } + } + + return nil + } + + private static func insertChild(parentID: String, label: String, in tree: inout TreeNode) -> TreeNode? { + if tree.id == parentID { + let node = TreeNode( + id: UUID().uuidString, + label: label, + claimedBy: nil, + children: [] + ) + tree.children.append(node) + return node + } + + for index in tree.children.indices { + if let created = insertChild(parentID: parentID, label: label, in: &tree.children[index]) { + return created + } + } + + return nil + } + + private static func removeNode(nodeID: String, from tree: inout TreeNode) -> Bool { + if let directChildIndex = tree.children.firstIndex(where: { $0.id == nodeID }) { + tree.children.remove(at: directChildIndex) + return true + } + + for index in tree.children.indices { + if removeNode(nodeID: nodeID, from: &tree.children[index]) { + return true + } + } + + return false + } + + @discardableResult + private static func detachNode(nodeID: String, from tree: inout TreeNode) -> TreeNode? { + if let index = tree.children.firstIndex(where: { $0.id == nodeID }) { + return tree.children.remove(at: index) + } + + for index in tree.children.indices { + if let detachedNode = detachNode(nodeID: nodeID, from: &tree.children[index]) { + return detachedNode + } + } + + return nil + } + + private static func renameNode(nodeID: String, newLabel: String, in tree: inout TreeNode) -> Bool { + if tree.id == nodeID { + tree.label = newLabel + return true + } + + for index in tree.children.indices { + if renameNode(nodeID: nodeID, newLabel: newLabel, in: &tree.children[index]) { + return true + } + } + + return false + } + + @discardableResult + private static func appendChild(_ child: TreeNode, toParentID parentID: String, in tree: inout TreeNode) -> Bool { + if tree.id == parentID { + tree.children.append(child) + return true + } + + for index in tree.children.indices { + if appendChild(child, toParentID: parentID, in: &tree.children[index]) { + return true + } + } + + return false + } + + private static func treeContainsNode(withID nodeID: String, in tree: TreeNode) -> Bool { + if tree.id == nodeID { + return true + } + + for child in tree.children { + if treeContainsNode(withID: nodeID, in: child) { + return true + } + } + + return false + } + + @discardableResult + private static func reorderNode(nodeID: String, beforeSiblingID: String, in tree: inout TreeNode) -> Bool { + let childIDs = tree.children.map(\.id) + if let fromIndex = childIDs.firstIndex(of: nodeID), + let toIndex = childIDs.firstIndex(of: beforeSiblingID) { + guard fromIndex != toIndex else { + return false + } + + let movingNode = tree.children.remove(at: fromIndex) + let destinationIndex = fromIndex < toIndex ? max(0, toIndex - 1) : toIndex + tree.children.insert(movingNode, at: destinationIndex) + return true + } + + for index in tree.children.indices { + if reorderNode(nodeID: nodeID, beforeSiblingID: beforeSiblingID, in: &tree.children[index]) { + return true + } + } + + return false + } +} diff --git a/TacNet/Resources/Info.plist b/TacNet/Resources/Info.plist new file mode 100644 index 00000000..48586890 --- /dev/null +++ b/TacNet/Resources/Info.plist @@ -0,0 +1,45 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + TacNet + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + NSBluetoothAlwaysUsageDescription + TacNet uses Bluetooth to discover nearby peers and relay tactical messages. + NSLocationWhenInUseUsageDescription + TacNet uses your location to attach situational coordinates to messages. + NSMicrophoneUsageDescription + TacNet needs microphone access for push-to-talk transcription. + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + UIApplicationSupportsIndirectInputEvents + + UILaunchScreen + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + + + diff --git a/TacNet/Resources/ParakeetCTC/.gitkeep b/TacNet/Resources/ParakeetCTC/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/TacNet/Services/BluetoothMeshService.swift b/TacNet/Services/BluetoothMeshService.swift new file mode 100644 index 00000000..0e8b2c5b --- /dev/null +++ b/TacNet/Services/BluetoothMeshService.swift @@ -0,0 +1,3637 @@ +import Foundation +import Combine +import CoreBluetooth +import AVFoundation + +struct BluetoothMeshUUIDs { + static let service = CBUUID(string: "7B4D8C10-3A8E-4D1A-9F53-2E28D9C1A001") + static let broadcastCharacteristic = CBUUID(string: "7B4D8C10-3A8E-4D1A-9F53-2E28D9C1A101") + static let compactionCharacteristic = CBUUID(string: "7B4D8C10-3A8E-4D1A-9F53-2E28D9C1A102") + static let treeConfigCharacteristic = CBUUID(string: "7B4D8C10-3A8E-4D1A-9F53-2E28D9C1A103") +} + +struct RecordedAudioClip: Sendable, Equatable { + let data: Data + let sampleRate: Int + let channels: Int + let bitsPerSample: Int + + var isPCM16kMono16Bit: Bool { + sampleRate == 16_000 && + channels == 1 && + bitsPerSample == 16 && + data.count.isMultiple(of: MemoryLayout.size) + } + + var bytesPerSecond: Int { + sampleRate * channels * bitsPerSample / 8 + } + + var durationSeconds: TimeInterval { + guard bytesPerSecond > 0 else { + return 0 + } + return TimeInterval(Double(data.count) / Double(bytesPerSecond)) + } + + func capped(to maximumDuration: TimeInterval) -> RecordedAudioClip { + guard maximumDuration > 0, bytesPerSecond > 0 else { + return self + } + + let maximumBytes = Int(Double(bytesPerSecond) * maximumDuration) + guard data.count > maximumBytes else { + return self + } + + return RecordedAudioClip( + data: Data(data.prefix(maximumBytes)), + sampleRate: sampleRate, + channels: channels, + bitsPerSample: bitsPerSample + ) + } + + func hasSpeech(amplitudeThreshold: Int16 = 500, minimumActiveSamples: Int = 160) -> Bool { + guard data.count.isMultiple(of: MemoryLayout.size), + minimumActiveSamples > 0 else { + return false + } + + let threshold = abs(Int(amplitudeThreshold)) + var activeSamples = 0 + var peakAmplitude = 0 + data.withUnsafeBytes { rawBuffer in + let samples = rawBuffer.bindMemory(to: Int16.self) + for sample in samples { + let amplitude = abs(Int(sample)) + if amplitude > peakAmplitude { peakAmplitude = amplitude } + if amplitude >= threshold { + activeSamples += 1 + if activeSamples >= minimumActiveSamples { + break + } + } + } + } + let detected = activeSamples >= minimumActiveSamples + NSLog("[Audio] Speech check — peak amplitude: %d, active samples: %d/%d, threshold: %d → %@", + peakAmplitude, activeSamples, minimumActiveSamples, threshold, + detected ? "SPEECH" : "SILENCE") + return detected + } +} + +enum AudioServiceError: Error, Equatable { + case alreadyRecording + case notRecording + case invalidAudioFormat + case captureFailed(String) +} + +protocol AudioCapturing: Sendable { + func startCapture() async throws + func stopCapture() async throws -> RecordedAudioClip +} + +protocol CactusTranscribing: Sendable { + func transcribePCM16kMono(_ pcmData: Data) async throws -> String +} + +protocol TranscriptConsuming: Sendable { + func receiveTranscript(_ transcript: AudioService.TranscriptResult) async +} + +actor CactusTranscriber: CactusTranscribing { + typealias TranscribeFunction = @Sendable (CactusModelT, Data) throws -> String + + private let modelHandleProvider: any ModelHandleProviding + private let transcribeFunction: TranscribeFunction + + init( + modelHandleProvider: any ModelHandleProviding = BundledModelInitializationService.parakeet, + transcribeFunction: @escaping TranscribeFunction = { model, pcmData in + try cactusTranscribe(model, nil, nil, nil, nil, pcmData) + } + ) { + self.modelHandleProvider = modelHandleProvider + self.transcribeFunction = transcribeFunction + NSLog("[STT] CactusTranscriber initialized with provider: %@", String(describing: type(of: modelHandleProvider))) + + // Verify Parakeet bundle resource exists at init time + if let parakeetPath = Bundle.main.path(forResource: "ParakeetCTC", ofType: nil) { + NSLog("[STT] ✅ ParakeetCTC found in bundle at: %@", parakeetPath) + } else { + NSLog("[STT] ❌ ParakeetCTC NOT found in app bundle — STT will fail. Bundle path: %@", Bundle.main.bundlePath) + // List top-level bundle contents for debugging + if let contents = try? FileManager.default.contentsOfDirectory(atPath: Bundle.main.bundlePath) { + let relevant = contents.filter { $0.contains("Parakeet") || $0.contains("cactus") || $0.hasSuffix(".weights") } + NSLog("[STT] Bundle contents (relevant): %@", relevant.isEmpty ? "(none)" : relevant.joined(separator: ", ")) + } + } + } + + func transcribePCM16kMono(_ pcmData: Data) async throws -> String { + NSLog("[STT] Loading model via %@", String(describing: type(of: modelHandleProvider))) + let modelHandle: CactusModelT + do { + modelHandle = try await modelHandleProvider.provideModelHandle() + } catch { + NSLog("[STT] ⚠️ Primary model failed (%@), falling back to Gemma", error.localizedDescription) + modelHandle = try await CactusModelInitializationService.shared.provideModelHandle() + } + NSLog("[STT] Model handle acquired, transcribing %d bytes of PCM audio", pcmData.count) + let responseJSON = try transcribeFunction(modelHandle, pcmData) + NSLog("[STT] Raw response: %@", responseJSON.prefix(200).description) + return Self.extractTranscript(from: responseJSON) + } + + private static func extractTranscript(from response: String) -> String { + let trimmedResponse = response.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedResponse.isEmpty else { + return "" + } + + guard let payload = trimmedResponse.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: payload) else { + return trimmedResponse + } + + guard let rootObject = json as? [String: Any] else { + return trimmedResponse + } + + if let transcript = firstNonEmptyTranscript(in: rootObject) { + return transcript + } + + if let nested = rootObject["result"] as? [String: Any], + let transcript = firstNonEmptyTranscript(in: nested) { + return transcript + } + + if let nestedArray = rootObject["results"] as? [[String: Any]] { + for item in nestedArray { + if let transcript = firstNonEmptyTranscript(in: item) { + return transcript + } + } + } + + return trimmedResponse + } + + private static func firstNonEmptyTranscript(in object: [String: Any]) -> String? { + for key in ["transcript", "response", "text"] { + if let value = object[key] as? String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + return trimmed + } + } + } + return nil + } +} + +protocol TacticalSummarizing: Sendable { + func summarize(systemPrompt: String, userPrompt: String) async throws -> String +} + +actor CactusTacticalSummarizer: TacticalSummarizing { + typealias CompleteFunction = @Sendable ( + CactusModelT, + String, + String?, + String?, + ((String, UInt32) -> Void)?, + Data? + ) throws -> String + + private struct PromptMessage: Codable { + let role: String + let content: String + } + + private let modelInitializationService: CactusModelInitializationService + private let completeFunction: CompleteFunction + private let optionsJSON: String + + init( + modelInitializationService: CactusModelInitializationService = .shared, + completeFunction: @escaping CompleteFunction = { model, messages, options, tools, onToken, pcm in + try cactusComplete(model, messages, options, tools, onToken, pcm) + }, + optionsJSON: String = #"{"max_tokens":96,"temperature":0.0}"# + ) { + self.modelInitializationService = modelInitializationService + self.completeFunction = completeFunction + self.optionsJSON = optionsJSON + } + + func summarize(systemPrompt: String, userPrompt: String) async throws -> String { + let modelHandle = try await modelInitializationService.initializeModelAfterEnsuringDownload() + let messagesJSON = try Self.messagesJSON(systemPrompt: systemPrompt, userPrompt: userPrompt) + let completion = try completeFunction(modelHandle, messagesJSON, optionsJSON, nil, nil, nil) + return Self.extractSummary(from: completion) + } + + private static func messagesJSON(systemPrompt: String, userPrompt: String) throws -> String { + let payload = [ + PromptMessage(role: "system", content: systemPrompt), + PromptMessage(role: "user", content: userPrompt) + ] + let data = try JSONEncoder().encode(payload) + return String(decoding: data, as: UTF8.self) + } + + private static func extractSummary(from response: String) -> String { + let trimmed = response.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, + let data = trimmed.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return trimmed + } + + if let responseValue = firstNonEmptyValue(for: ["response", "summary", "text"], in: json) { + return responseValue + } + + if let nestedResult = json["result"] as? [String: Any], + let responseValue = firstNonEmptyValue(for: ["response", "summary", "text"], in: nestedResult) { + return responseValue + } + + return trimmed + } + + private static func firstNonEmptyValue(for keys: [String], in object: [String: Any]) -> String? { + for key in keys { + if let value = object[key] as? String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + return trimmed + } + } + } + return nil + } +} + +actor CompactionEngine { + enum TriggerReason: String, Equatable, Sendable { + case timeWindow + case messageCount + case priorityKeyword + case manual + } + + enum ProcessingStatus: String, Equatable, Sendable { + case idle + case compacting + } + + struct Configuration: Equatable, Sendable { + var timeWindow: TimeInterval + var messageCountThreshold: Int + var defaultTTL: Int + + init( + timeWindow: TimeInterval = 8, + messageCountThreshold: Int = 5, + defaultTTL: Int = 8 + ) { + self.timeWindow = max(0, timeWindow) + self.messageCountThreshold = max(1, messageCountThreshold) + self.defaultTTL = max(1, defaultTTL) + } + } + + struct ProcessingMetrics: Equatable, Sendable { + let status: ProcessingStatus + let triggerReason: TriggerReason? + let latencyMilliseconds: Double? + let inputTokenCount: Int + let outputTokenCount: Int + let compressionRatio: Double? + let sourceMessageCount: Int + let updatedAt: Date + + static func idle(updatedAt: Date) -> ProcessingMetrics { + ProcessingMetrics( + status: .idle, + triggerReason: nil, + latencyMilliseconds: nil, + inputTokenCount: 0, + outputTokenCount: 0, + compressionRatio: nil, + sourceMessageCount: 0, + updatedAt: updatedAt + ) + } + } + + struct CompactionEmission: Equatable, Sendable { + let message: Message + let triggerReason: TriggerReason + let sourceMessageCount: Int + let sourceNodeIDs: [String] + let destinationNodeID: String? + let outputText: String + let summaryWordCount: Int + let inputTokenCount: Int + let outputTokenCount: Int + let compressionRatio: Double + let latencyMilliseconds: Double + let generatedAt: Date + } + + struct SITREP: Equatable, Sendable { + let text: String + let triggerReason: TriggerReason + let sourceMessageCount: Int + let generatedAt: Date + } + + typealias ProcessingObserver = @Sendable (ProcessingMetrics) -> Void + typealias CompactionEmissionObserver = @Sendable (CompactionEmission) -> Void + + private struct QueueItem: Sendable { + let body: String + let sourceNodeID: String + let receivedAt: Date + } + + private let localDeviceID: String + private let localNodeID: String + private let localSenderRole: String + private let messageRouter: MessageRouter + private let summarizer: any TacticalSummarizing + private let configuration: Configuration + private let now: @Sendable () -> Date + private let sleep: @Sendable (UInt64) async -> Void + + private var tree: TreeNode + + private var queuedChildTranscripts: [QueueItem] = [] + private var queuedL1Compactions: [QueueItem] = [] + + private var childCompactionTimerTask: Task? + private var sitrepTimerTask: Task? + + private var compactionEmissionsStorage: [CompactionEmission] = [] + private var latestSitrepStorage: SITREP? + private var processingMetricsStorage: ProcessingMetrics = .idle(updatedAt: .distantPast) + private var processingObserver: ProcessingObserver? + private var compactionEmissionObserver: CompactionEmissionObserver? + + init( + localDeviceID: String, + localNodeID: String, + localSenderRole: String, + initialTree: TreeNode, + messageRouter: MessageRouter = MessageRouter(), + summarizer: any TacticalSummarizing = CactusTacticalSummarizer(), + configuration: Configuration = Configuration(), + now: @escaping @Sendable () -> Date = { Date() }, + sleep: @escaping @Sendable (UInt64) async -> Void = { nanoseconds in + try? await Task.sleep(nanoseconds: nanoseconds) + } + ) { + self.localDeviceID = localDeviceID + self.localNodeID = localNodeID + self.localSenderRole = localSenderRole + self.tree = initialTree + self.messageRouter = messageRouter + self.summarizer = summarizer + self.configuration = configuration + self.now = now + self.sleep = sleep + processingMetricsStorage = .idle(updatedAt: self.now()) + } + + deinit { + childCompactionTimerTask?.cancel() + sitrepTimerTask?.cancel() + } + + func updateTree(_ tree: TreeNode) { + self.tree = tree + } + + func enqueueChildTranscript(_ transcript: String, from childNodeID: String) async { + guard isDirectChild(nodeID: childNodeID) else { + return + } + + let normalizedTranscript = normalizedInput(transcript) + guard !normalizedTranscript.isEmpty else { + return + } + + queuedChildTranscripts.append( + QueueItem(body: normalizedTranscript, sourceNodeID: childNodeID, receivedAt: now()) + ) + + if Self.containsPriorityKeyword(in: normalizedTranscript) { + await triggerChildCompaction(reason: .priorityKeyword) + return + } + + if queuedChildTranscripts.count >= configuration.messageCountThreshold { + await triggerChildCompaction(reason: .messageCount) + return + } + + scheduleChildCompactionTimerIfNeeded() + } + + func enqueueL1CompactionSummary(_ summary: String, from childNodeID: String) async { + guard isRootNode, isDirectChild(nodeID: childNodeID) else { + return + } + + let normalizedSummary = normalizedInput(summary) + guard !normalizedSummary.isEmpty else { + return + } + + queuedL1Compactions.append( + QueueItem(body: normalizedSummary, sourceNodeID: childNodeID, receivedAt: now()) + ) + + if Self.containsPriorityKeyword(in: normalizedSummary) { + await triggerSITREP(reason: .priorityKeyword) + return + } + + if queuedL1Compactions.count >= configuration.messageCountThreshold { + await triggerSITREP(reason: .messageCount) + return + } + + scheduleSITREPTimerIfNeeded() + } + + func flushQueuedChildTranscripts() async { + await triggerChildCompaction(reason: .manual) + } + + func flushQueuedL1Compactions() async { + await triggerSITREP(reason: .manual) + } + + func emittedCompactions() -> [CompactionEmission] { + compactionEmissionsStorage + } + + func latestSITREP() -> SITREP? { + latestSitrepStorage + } + + func latestProcessingMetrics() -> ProcessingMetrics { + processingMetricsStorage + } + + func setProcessingObserver(_ observer: ProcessingObserver?) { + processingObserver = observer + observer?(processingMetricsStorage) + } + + func setCompactionEmissionObserver(_ observer: CompactionEmissionObserver?) { + compactionEmissionObserver = observer + } + + private var isRootNode: Bool { + TreeHelpers.level(of: localNodeID, in: tree) == 0 + } + + private func isDirectChild(nodeID: String) -> Bool { + TreeHelpers.parent(of: nodeID, in: tree)?.id == localNodeID + } + + private func scheduleChildCompactionTimerIfNeeded() { + guard childCompactionTimerTask == nil, !queuedChildTranscripts.isEmpty else { + return + } + + let delayNanoseconds = Self.nanoseconds(from: configuration.timeWindow) + childCompactionTimerTask = Task { [sleep] in + await sleep(delayNanoseconds) + guard !Task.isCancelled else { + return + } + await self.handleChildCompactionTimerFired() + } + } + + private func scheduleSITREPTimerIfNeeded() { + guard sitrepTimerTask == nil, !queuedL1Compactions.isEmpty else { + return + } + + let delayNanoseconds = Self.nanoseconds(from: configuration.timeWindow) + sitrepTimerTask = Task { [sleep] in + await sleep(delayNanoseconds) + guard !Task.isCancelled else { + return + } + await self.handleSITREPTimerFired() + } + } + + private func handleChildCompactionTimerFired() async { + childCompactionTimerTask = nil + guard !queuedChildTranscripts.isEmpty else { + return + } + await triggerChildCompaction(reason: .timeWindow) + } + + private func handleSITREPTimerFired() async { + sitrepTimerTask = nil + guard !queuedL1Compactions.isEmpty else { + return + } + await triggerSITREP(reason: .timeWindow) + } + + private func triggerChildCompaction(reason: TriggerReason) async { + guard !queuedChildTranscripts.isEmpty else { + return + } + + childCompactionTimerTask?.cancel() + childCompactionTimerTask = nil + + let queuedItems = queuedChildTranscripts + queuedChildTranscripts.removeAll(keepingCapacity: true) + + guard let destinationNodeID = TreeHelpers.parent(of: localNodeID, in: tree)?.id else { + return + } + + let processingStartedAt = now() + let inputTokenCount = Self.estimatedTokenCount(in: queuedItems.map(\.body).joined(separator: " ")) + updateProcessingMetrics( + ProcessingMetrics( + status: .compacting, + triggerReason: reason, + latencyMilliseconds: nil, + inputTokenCount: inputTokenCount, + outputTokenCount: 0, + compressionRatio: nil, + sourceMessageCount: queuedItems.count, + updatedAt: processingStartedAt + ) + ) + + let summary = await summarizeChildTranscripts(queuedItems) + let processingFinishedAt = now() + let latencyMilliseconds = processingFinishedAt.timeIntervalSince(processingStartedAt) * 1_000 + let outputTokenCount = Self.estimatedTokenCount(in: summary) + let compressionRatio = Self.compressionRatio( + inputTokenCount: inputTokenCount, + outputTokenCount: outputTokenCount + ) + + guard !summary.isEmpty else { + updateProcessingMetrics( + ProcessingMetrics( + status: .idle, + triggerReason: reason, + latencyMilliseconds: latencyMilliseconds, + inputTokenCount: inputTokenCount, + outputTokenCount: outputTokenCount, + compressionRatio: compressionRatio, + sourceMessageCount: queuedItems.count, + updatedAt: processingFinishedAt + ) + ) + return + } + + let timestamp = processingFinishedAt + let compactionMessage = messageRouter.makeCompactionMessage( + summary: summary, + senderID: localDeviceID, + senderNodeID: localNodeID, + senderRole: localSenderRole, + in: tree, + ttl: configuration.defaultTTL, + encrypted: false, + timestamp: timestamp + ) + + compactionEmissionsStorage.append( + CompactionEmission( + message: compactionMessage, + triggerReason: reason, + sourceMessageCount: queuedItems.count, + sourceNodeIDs: Self.uniqueSourceNodeIDs(from: queuedItems), + destinationNodeID: destinationNodeID, + outputText: summary, + summaryWordCount: Self.wordCount(in: summary), + inputTokenCount: inputTokenCount, + outputTokenCount: outputTokenCount, + compressionRatio: compressionRatio ?? 0, + latencyMilliseconds: latencyMilliseconds, + generatedAt: timestamp + ) + ) + + if let latestEmission = compactionEmissionsStorage.last { + compactionEmissionObserver?(latestEmission) + } + + updateProcessingMetrics( + ProcessingMetrics( + status: .idle, + triggerReason: reason, + latencyMilliseconds: latencyMilliseconds, + inputTokenCount: inputTokenCount, + outputTokenCount: outputTokenCount, + compressionRatio: compressionRatio, + sourceMessageCount: queuedItems.count, + updatedAt: processingFinishedAt + ) + ) + } + + private func triggerSITREP(reason: TriggerReason) async { + guard !queuedL1Compactions.isEmpty else { + return + } + + sitrepTimerTask?.cancel() + sitrepTimerTask = nil + + guard isRootNode else { + queuedL1Compactions.removeAll(keepingCapacity: true) + return + } + + let queuedItems = queuedL1Compactions + queuedL1Compactions.removeAll(keepingCapacity: true) + + let processingStartedAt = now() + let inputTokenCount = Self.estimatedTokenCount(in: queuedItems.map(\.body).joined(separator: " ")) + updateProcessingMetrics( + ProcessingMetrics( + status: .compacting, + triggerReason: reason, + latencyMilliseconds: nil, + inputTokenCount: inputTokenCount, + outputTokenCount: 0, + compressionRatio: nil, + sourceMessageCount: queuedItems.count, + updatedAt: processingStartedAt + ) + ) + + let sitrepText = await summarizeL1Compactions(queuedItems) + let processingFinishedAt = now() + let latencyMilliseconds = processingFinishedAt.timeIntervalSince(processingStartedAt) * 1_000 + let outputTokenCount = Self.estimatedTokenCount(in: sitrepText) + let compressionRatio = Self.compressionRatio( + inputTokenCount: inputTokenCount, + outputTokenCount: outputTokenCount + ) + + guard !sitrepText.isEmpty else { + updateProcessingMetrics( + ProcessingMetrics( + status: .idle, + triggerReason: reason, + latencyMilliseconds: latencyMilliseconds, + inputTokenCount: inputTokenCount, + outputTokenCount: outputTokenCount, + compressionRatio: compressionRatio, + sourceMessageCount: queuedItems.count, + updatedAt: processingFinishedAt + ) + ) + return + } + + latestSitrepStorage = SITREP( + text: sitrepText, + triggerReason: reason, + sourceMessageCount: queuedItems.count, + generatedAt: processingFinishedAt + ) + + updateProcessingMetrics( + ProcessingMetrics( + status: .idle, + triggerReason: reason, + latencyMilliseconds: latencyMilliseconds, + inputTokenCount: inputTokenCount, + outputTokenCount: outputTokenCount, + compressionRatio: compressionRatio, + sourceMessageCount: queuedItems.count, + updatedAt: processingFinishedAt + ) + ) + } + + private func summarizeChildTranscripts(_ transcripts: [QueueItem]) async -> String { + await summarize( + entries: transcripts, + systemPrompt: Self.compactionSystemPrompt, + promptPrefix: "Summarize these child tactical transcripts into one update:" + ) + } + + private func summarizeL1Compactions(_ compactions: [QueueItem]) async -> String { + await summarize( + entries: compactions, + systemPrompt: Self.sitrepSystemPrompt, + promptPrefix: "Produce a commander SITREP from these L1 compactions:" + ) + } + + private func summarize( + entries: [QueueItem], + systemPrompt: String, + promptPrefix: String + ) async -> String { + let numberedLines = entries.enumerated().map { index, item in + "\(index + 1). \(item.body)" + }.joined(separator: "\n") + let userPrompt = "\(promptPrefix)\n\(numberedLines)" + + let generated: String + do { + generated = try await summarizer.summarize(systemPrompt: systemPrompt, userPrompt: userPrompt) + } catch { + generated = entries.map(\.body).joined(separator: " ") + } + + return Self.sanitizeSummary(generated, maxWords: 30) + } + + private func normalizedInput(_ value: String) -> String { + value + .replacingOccurrences(of: "\n", with: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + private func updateProcessingMetrics(_ metrics: ProcessingMetrics) { + processingMetricsStorage = metrics + processingObserver?(metrics) + } + + private static let compactionSystemPrompt = """ + You are TacNet's tactical summarizer. + Preserve critical information: locations, threats, casualty details, and unit status. + Remove filler language (uh, um, copy that, roger, say again, over). + Return plain text only and keep the summary under 30 words. + """ + + private static let sitrepSystemPrompt = """ + You are TacNet's root SITREP summarizer. + Preserve critical information: locations, threats, casualty details, and unit status from L1 compactions. + Remove filler language (uh, um, copy that, roger, say again, over). + Return plain text only and keep the SITREP under 30 words. + """ + + private static let priorityKeywordRegex: NSRegularExpression = { + let pattern = #"(? Bool { + let sourceRange = NSRange(text.startIndex.. String { + var value = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty else { + return "" + } + + value = replacingMatches(#"(?i)\bcopy\s+that\b"#, in: value, with: " ") + value = replacingMatches(#"(?i)\bsay\s+again\b"#, in: value, with: " ") + value = replacingMatches(#"(?i)\b(?:uh+|um+|roger|over)\b"#, in: value, with: " ") + value = replacingMatches(#"\s+"#, in: value, with: " ") + value = value.trimmingCharacters(in: .whitespacesAndNewlines) + + let words = value.split(whereSeparator: \.isWhitespace) + guard !words.isEmpty else { + return "No actionable update." + } + + return words.prefix(max(1, maxWords)).joined(separator: " ") + } + + private static func replacingMatches(_ pattern: String, in value: String, with replacement: String) -> String { + guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { + return value + } + let range = NSRange(value.startIndex.. UInt64 { + let clamped = max(0, seconds) + return UInt64(clamped * 1_000_000_000) + } + + private static func estimatedTokenCount(in value: String) -> Int { + wordCount(in: value) + } + + private static func compressionRatio(inputTokenCount: Int, outputTokenCount: Int) -> Double? { + guard inputTokenCount > 0 else { + return nil + } + return Double(outputTokenCount) / Double(inputTokenCount) + } + + private static func uniqueSourceNodeIDs(from queuedItems: [QueueItem]) -> [String] { + var seen: Set = [] + var orderedUniqueIDs: [String] = [] + + for item in queuedItems where !item.sourceNodeID.isEmpty { + if seen.insert(item.sourceNodeID).inserted { + orderedUniqueIDs.append(item.sourceNodeID) + } + } + + return orderedUniqueIDs + } + + private static func wordCount(in value: String) -> Int { + value.split(whereSeparator: \.isWhitespace).count + } +} + +final class AVAudioEngineRecorder: NSObject, AudioCapturing, @unchecked Sendable { + private let audioEngine: AVAudioEngine + private let targetFormat: AVAudioFormat + private let maxCapturedBytes: Int + private let captureLock = NSLock() + + private var converter: AVAudioConverter? + private var capturedPCMData: Data = Data() + private var hasReachedCaptureLimit = false + private var isCapturing = false + + init( + audioEngine: AVAudioEngine = AVAudioEngine(), + maxRecordingDuration: TimeInterval = 60 + ) { + self.audioEngine = audioEngine + self.targetFormat = AVAudioFormat( + commonFormat: .pcmFormatInt16, + sampleRate: 16_000, + channels: 1, + interleaved: true + )! + let cappedDuration = max(1, Int(ceil(maxRecordingDuration))) + self.maxCapturedBytes = 16_000 * MemoryLayout.size * cappedDuration + super.init() + } + + func startCapture() async throws { + do { + try await MainActor.run { + try startCaptureOnMainActor() + } + } catch let error as AudioServiceError { + throw error + } catch { + throw AudioServiceError.captureFailed(error.localizedDescription) + } + } + + func stopCapture() async throws -> RecordedAudioClip { + do { + try await MainActor.run { + try stopCaptureOnMainActor() + } + } catch let error as AudioServiceError { + throw error + } catch { + throw AudioServiceError.captureFailed(error.localizedDescription) + } + + let clipData = consumeCapturedPCMData() + + return RecordedAudioClip( + data: clipData, + sampleRate: 16_000, + channels: 1, + bitsPerSample: 16 + ) + } + + @MainActor + private func startCaptureOnMainActor() throws { + guard !isCapturing else { + throw AudioServiceError.alreadyRecording + } + + // Log microphone permission status before touching the audio engine. + let micStatus: String + switch AVAudioApplication.shared.recordPermission { + case .granted: micStatus = "granted" + case .denied: micStatus = "DENIED" + case .undetermined: micStatus = "undetermined" + @unknown default: micStatus = "unknown" + } + NSLog("[Audio] Microphone permission: %@", micStatus) + guard AVAudioApplication.shared.recordPermission == .granted else { + NSLog("[Audio] ❌ Microphone permission not granted — recording will be silent") + // Don't throw here; let the user see the permission prompt via the UI. + // We continue so that if permission is later granted the engine can start. + return + } + + // Configure the audio session for recording BEFORE touching AVAudioEngine. + // The default category (.soloAmbient) is playback-only; without .playAndRecord + // the inputNode produces silence with no error, which fails the hasSpeech check. + let session = AVAudioSession.sharedInstance() + do { + // .voiceChat enables proper mic gain + echo cancellation for speech input. + // .measurement was wrong — it disables hardware gain, causing near-zero amplitude. + try session.setCategory(.playAndRecord, mode: .voiceChat, options: [.defaultToSpeaker, .allowBluetoothHFP]) + try session.setActive(true) + NSLog("[Audio] Session category set to playAndRecord/voiceChat — input available: %@, gain: %.2f", + session.isInputAvailable ? "yes" : "no", session.inputGain) + } catch { + NSLog("[Audio] ❌ Failed to configure audio session: %@", error.localizedDescription) + throw AudioServiceError.captureFailed("Audio session setup failed: \(error.localizedDescription)") + } + + // NOTE: Do NOT call audioEngine.reset() here — it disconnects the inputNode + // from hardware and causes near-zero amplitude (silence) on the tap. + + let inputNode = audioEngine.inputNode + let inputFormat = inputNode.outputFormat(forBus: 0) + NSLog("[Audio] Input node format — sample rate: %.0f Hz, channels: %d, formatID: %u", + inputFormat.sampleRate, inputFormat.channelCount, + inputFormat.streamDescription.pointee.mFormatID) + + guard let converter = AVAudioConverter(from: inputFormat, to: targetFormat) else { + throw AudioServiceError.invalidAudioFormat + } + + self.converter = converter + captureLock.lock() + capturedPCMData.removeAll(keepingCapacity: true) + hasReachedCaptureLimit = false + captureLock.unlock() + + inputNode.removeTap(onBus: 0) + var tapCallCount = 0 + inputNode.installTap(onBus: 0, bufferSize: 2048, format: inputFormat) { [weak self] buffer, _ in + tapCallCount += 1 + if tapCallCount <= 3 || tapCallCount % 20 == 0 { + // Log first 3 callbacks and every 20th after to confirm tap is firing + var peak: Int16 = 0 + if let ch = buffer.floatChannelData { + let frameCount = Int(buffer.frameLength) + for i in 0.. abs(peak) { peak = sample } + } + } + NSLog("[Audio] TAP callback #%d — frames: %d, peak: %d", + tapCallCount, buffer.frameLength, peak) + } + self?.appendConvertedBuffer(buffer) + } + + do { + audioEngine.prepare() + try audioEngine.start() + isCapturing = true + NSLog("[Audio] ✅ Recording started — engine running: %@", audioEngine.isRunning ? "yes" : "no") + } catch { + inputNode.removeTap(onBus: 0) + self.converter = nil + NSLog("[Audio] ❌ Failed to start audio engine: %@", error.localizedDescription) + throw AudioServiceError.captureFailed(error.localizedDescription) + } + } + + @MainActor + private func stopCaptureOnMainActor() throws { + guard isCapturing else { + throw AudioServiceError.notRecording + } + + audioEngine.inputNode.removeTap(onBus: 0) + audioEngine.stop() + converter = nil + isCapturing = false + } + + private func appendConvertedBuffer(_ inputBuffer: AVAudioPCMBuffer) { + captureLock.lock() + defer { captureLock.unlock() } + + guard !hasReachedCaptureLimit, let converter else { + return + } + + let rateRatio = targetFormat.sampleRate / inputBuffer.format.sampleRate + let outputCapacity = AVAudioFrameCount(max(1, Int(Double(inputBuffer.frameLength) * rateRatio) + 16)) + guard let outputBuffer = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: outputCapacity) else { + return + } + + var hasSuppliedInput = false + var conversionError: NSError? + let conversionStatus = converter.convert(to: outputBuffer, error: &conversionError) { _, outStatus in + if hasSuppliedInput { + outStatus.pointee = .endOfStream + return nil + } + hasSuppliedInput = true + outStatus.pointee = .haveData + return inputBuffer + } + + guard conversionError == nil, + conversionStatus != .error, + outputBuffer.frameLength > 0, + let channelData = outputBuffer.int16ChannelData else { + return + } + + let byteCount = Int(outputBuffer.frameLength) * MemoryLayout.size + let convertedData = Data(bytes: channelData.pointee, count: byteCount) + + if capturedPCMData.count + convertedData.count > maxCapturedBytes { + let remainingBytes = max(0, maxCapturedBytes - capturedPCMData.count) + if remainingBytes > 0 { + capturedPCMData.append(convertedData.prefix(remainingBytes)) + } + hasReachedCaptureLimit = true + return + } + + capturedPCMData.append(convertedData) + } + + private func consumeCapturedPCMData() -> Data { + captureLock.lock() + defer { captureLock.unlock() } + let clipData = capturedPCMData + capturedPCMData.removeAll(keepingCapacity: true) + return clipData + } +} + +actor AudioService { + struct TranscriptResult: Equatable, Sendable { + let sequence: Int + let transcript: String + let clipDurationSeconds: TimeInterval + } + + private struct PendingClip: Sendable { + let sequence: Int + let clip: RecordedAudioClip + } + + private let capturer: AudioCapturing + private let transcriber: CactusTranscribing + private let transcriptConsumer: TranscriptConsuming? + private let maxRecordingDuration: TimeInterval + private let silenceAmplitudeThreshold: Int16 + private let minimumActiveSamples: Int + + private var isRecording = false + private var nextSequence = 0 + private var pendingClips: [PendingClip] = [] + private var processingTask: Task? + + private(set) var transcriptHistory: [TranscriptResult] = [] + + init( + capturer: AudioCapturing = AVAudioEngineRecorder(), + transcriber: CactusTranscribing = CactusTranscriber(), + transcriptConsumer: TranscriptConsuming? = nil, + maxRecordingDuration: TimeInterval = 60, + silenceAmplitudeThreshold: Int16 = 500, + minimumActiveSamples: Int = 160 + ) { + self.capturer = capturer + self.transcriber = transcriber + self.transcriptConsumer = transcriptConsumer + self.maxRecordingDuration = maxRecordingDuration + self.silenceAmplitudeThreshold = silenceAmplitudeThreshold + self.minimumActiveSamples = minimumActiveSamples + } + + func pttPressed() async throws { + guard !isRecording else { + throw AudioServiceError.alreadyRecording + } + + do { + try await capturer.startCapture() + isRecording = true + } catch let error as AudioServiceError { + throw error + } catch { + throw AudioServiceError.captureFailed(error.localizedDescription) + } + } + + @discardableResult + func pttReleased() async throws -> Int? { + guard isRecording else { + throw AudioServiceError.notRecording + } + + let capturedClip: RecordedAudioClip + do { + capturedClip = try await capturer.stopCapture() + isRecording = false + } catch { + isRecording = false + if let audioError = error as? AudioServiceError { + throw audioError + } + throw AudioServiceError.captureFailed(error.localizedDescription) + } + + let clippedAudio = capturedClip.capped(to: maxRecordingDuration) + guard clippedAudio.isPCM16kMono16Bit else { + throw AudioServiceError.invalidAudioFormat + } + + guard clippedAudio.hasSpeech( + amplitudeThreshold: silenceAmplitudeThreshold, + minimumActiveSamples: minimumActiveSamples + ) else { + return nil + } + + let sequence = nextSequence + nextSequence += 1 + pendingClips.append(PendingClip(sequence: sequence, clip: clippedAudio)) + startProcessingIfNeeded() + return sequence + } + + func waitForIdle() async { + while processingTask != nil || !pendingClips.isEmpty { + try? await Task.sleep(nanoseconds: 10_000_000) + } + } + + private func startProcessingIfNeeded() { + guard processingTask == nil else { + return + } + + processingTask = Task { + await processPendingClips() + } + } + + private func processPendingClips() async { + while !pendingClips.isEmpty { + let item = pendingClips.removeFirst() + + do { + let transcript = try await transcriber.transcribePCM16kMono(item.clip.data) + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !transcript.isEmpty else { + continue + } + + let result = TranscriptResult( + sequence: item.sequence, + transcript: transcript, + clipDurationSeconds: item.clip.durationSeconds + ) + transcriptHistory.append(result) + if let transcriptConsumer { + await transcriptConsumer.receiveTranscript(result) + } + } catch { + continue + } + } + + processingTask = nil + if !pendingClips.isEmpty { + startProcessingIfNeeded() + } + } +} + +enum PeerConnectionState: String, Equatable, Sendable { + case connected + case disconnected +} + +struct NetworkAdvertisement: Equatable, Sendable { + let networkID: UUID + let networkName: String + let openSlotCount: Int + let requiresPIN: Bool + + init(networkID: UUID, networkName: String, openSlotCount: Int, requiresPIN: Bool) { + self.networkID = networkID + self.networkName = networkName + self.openSlotCount = max(0, openSlotCount) + self.requiresPIN = requiresPIN + } + + init(from config: NetworkConfig) { + self.init( + networkID: config.networkID, + networkName: config.networkName, + openSlotCount: config.openSlotCount, + requiresPIN: config.requiresPIN + ) + } +} + +struct DiscoveredNetwork: Identifiable, Equatable, Sendable { + var id: UUID { peerID } + let peerID: UUID + let networkID: UUID + let networkName: String + let openSlotCount: Int + let requiresPIN: Bool +} + +private enum NetworkAdvertisementCodec { + // CBPeripheralManager.startAdvertising only supports CBAdvertisementDataLocalNameKey + // and CBAdvertisementDataServiceUUIDsKey. ServiceData is a read-only central-side key + // and will crash with NSInvalidArgumentException if passed to startAdvertising. + // + // Metadata encoding: append "|XX" to the local name where XX is one hex byte: + // bits [7:4] = openSlotCount clamped to 0-15 + // bit [0] = requiresPIN flag + // The real networkID is fetched via GATT treeConfig read during join. + + private static let metaSuffixLength = 3 // "|XX" + private static let maxNetworkNameLength = 17 // 20 - 3 = 17 chars for the name itself + + static func advertisingData(for summary: NetworkAdvertisement) -> [String: Any] { + let clampedSlots = min(summary.openSlotCount, 15) + let metaByte = UInt8((clampedSlots << 1) | (summary.requiresPIN ? 1 : 0)) + let metaSuffix = String(format: "|%02X", metaByte) + + let trimmedName = String(summary.networkName.prefix(maxNetworkNameLength)) + let advertisedName = trimmedName + metaSuffix + + return [ + CBAdvertisementDataServiceUUIDsKey: [BluetoothMeshUUIDs.service], + CBAdvertisementDataLocalNameKey: advertisedName + ] + } + + // peerID is used as a proxy networkID; the real networkID is confirmed during GATT join. + static func decode(advertisementData: [String: Any], peerID: UUID) -> NetworkAdvertisement? { + guard let serviceUUIDs = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID], + serviceUUIDs.contains(BluetoothMeshUUIDs.service) else { + return nil + } + + let rawName = (advertisementData[CBAdvertisementDataLocalNameKey] as? String) ?? "" + + var networkName = rawName + var openSlotCount = 0 + var requiresPIN = false + + if rawName.count >= metaSuffixLength { + let suffix = String(rawName.suffix(metaSuffixLength)) + if suffix.first == "|", let metaByte = UInt8(suffix.dropFirst(), radix: 16) { + networkName = String(rawName.dropLast(metaSuffixLength)) + requiresPIN = (metaByte & 0x01) != 0 + openSlotCount = Int((metaByte >> 1) & 0x0F) + } + } + + networkName = networkName.trimmingCharacters(in: .whitespacesAndNewlines) + if networkName.isEmpty { networkName = "TacNet Network" } + + return NetworkAdvertisement( + networkID: peerID, + networkName: networkName, + openSlotCount: openSlotCount, + requiresPIN: requiresPIN + ) + } +} + +enum BluetoothMeshTransportError: Error { + case unsupportedTreeConfigRead + case unknownPeer + case treeConfigUnavailable +} + +enum BluetoothMeshTransportEvent: Sendable { + case discoveredPeer(UUID) + case discoveredNetwork(UUID, NetworkAdvertisement) + case connectionStateChanged(UUID, PeerConnectionState) + case receivedData(Data, from: UUID) +} + +protocol BluetoothMeshTransporting: AnyObject { + var eventHandler: ((BluetoothMeshTransportEvent) -> Void)? { get set } + + func start() + func stop() + func send(_ data: Data, messageType: Message.MessageType, to peerIDs: Set) + func configureAdvertisement(_ summary: NetworkAdvertisement?) + func updateTreeConfigPayload(_ data: Data) + func requestTreeConfig(from peerID: UUID, completion: @escaping (Result) -> Void) +} + +extension BluetoothMeshTransporting { + func configureAdvertisement(_: NetworkAdvertisement?) {} + + func updateTreeConfigPayload(_: Data) {} + + func requestTreeConfig(from _: UUID, completion: @escaping (Result) -> Void) { + completion(.failure(BluetoothMeshTransportError.unsupportedTreeConfigRead)) + } +} + +private enum BluetoothMeshCharacteristicKind: CaseIterable { + case broadcast + case compaction + case treeConfig + + var uuid: CBUUID { + switch self { + case .broadcast: + return BluetoothMeshUUIDs.broadcastCharacteristic + case .compaction: + return BluetoothMeshUUIDs.compactionCharacteristic + case .treeConfig: + return BluetoothMeshUUIDs.treeConfigCharacteristic + } + } +} + +final class BluetoothMeshService { + typealias MessageHandler = (Message) -> Void + typealias PeerStateHandler = (UUID, PeerConnectionState) -> Void + typealias PeerDiscoveryHandler = (UUID) -> Void + typealias NetworkDiscoveryHandler = (UUID, NetworkAdvertisement) -> Void + + var onMessageReceived: MessageHandler? + var onPeerConnectionStateChanged: PeerStateHandler? + var onPeerDiscovered: PeerDiscoveryHandler? + var onNetworkDiscovered: NetworkDiscoveryHandler? + + private let transport: BluetoothMeshTransporting + private let deduplicator: MessageDeduplicator + private let encryptionService: NetworkEncryptionService + private let encoder = JSONEncoder() + private let decoder = JSONDecoder() + + private var peerStates: [UUID: PeerConnectionState] = [:] + + private struct QueuedRelay { + let message: Message + let excludedPeerID: UUID? + } + private var relayQueue: [QueuedRelay] = [] + + init( + transport: BluetoothMeshTransporting = CoreBluetoothMeshTransport(), + deduplicator: MessageDeduplicator = MessageDeduplicator(), + encryptionService: NetworkEncryptionService = NetworkEncryptionService() + ) { + self.transport = transport + self.deduplicator = deduplicator + self.encryptionService = encryptionService + self.transport.eventHandler = { [weak self] event in + self?.handleTransportEvent(event) + } + } + + func start() { + transport.start() + } + + func stop() { + transport.stop() + } + + func publishNetwork(_ networkConfig: NetworkConfig) { + transport.configureAdvertisement(NetworkAdvertisement(from: networkConfig)) + if let payload = try? encoder.encode(networkConfig) { + transport.updateTreeConfigPayload(payload) + } + start() + } + + func updatePublishedNetwork(_ networkConfig: NetworkConfig) { + publishNetwork(networkConfig) + } + + func clearPublishedNetwork() { + transport.configureAdvertisement(nil) + transport.updateTreeConfigPayload(Data()) + clearSessionKey() + } + + func fetchNetworkConfig(from peerID: UUID, completion: @escaping (Result) -> Void) { + transport.requestTreeConfig(from: peerID) { result in + switch result { + case .success(let data): + do { + let decoded = try JSONDecoder().decode(NetworkConfig.self, from: data) + completion(.success(decoded)) + } catch { + completion(.failure(error)) + } + case .failure(let error): + completion(.failure(error)) + } + } + } + + func fetchNetworkConfig(from peerID: UUID) async throws -> NetworkConfig { + try await withCheckedThrowingContinuation { continuation in + fetchNetworkConfig(from: peerID) { result in + continuation.resume(with: result) + } + } + } + + func publish(_ message: Message) { + var outboundMessage = message + if encryptionService.hasActiveSessionKey { + outboundMessage.payload.encrypted = true + } + + guard outboundMessage.ttl > 0 else { + return + } + + guard !deduplicator.isDuplicate(messageId: outboundMessage.id) else { + return + } + + flood(outboundMessage, excluding: nil) + } + + func hasActiveSessionKey(for networkID: UUID) -> Bool { + encryptionService.hasSessionKey(for: networkID) + } + + func prepareSessionKeyForPublishing(networkID: UUID, keyMaterial: String) throws -> String { + try encryptionService.makeWrappedSessionKey(networkID: networkID, keyMaterial: keyMaterial) + } + + func activateSessionKey( + networkID: UUID, + wrappedSessionKey: String, + keyMaterial: String + ) throws { + try encryptionService.activateSessionKey( + networkID: networkID, + wrappedSessionKey: wrappedSessionKey, + keyMaterial: keyMaterial + ) + } + + func activateDeterministicSessionKey(networkID: UUID, keyMaterial: String) { + encryptionService.activateDeterministicSessionKey(networkID: networkID, keyMaterial: keyMaterial) + } + + func clearSessionKey() { + encryptionService.clearSessionKey() + } + + func connectionState(for peerID: UUID) -> PeerConnectionState { + peerStates[peerID] ?? .disconnected + } + + var connectedPeerIDs: Set { + Set( + peerStates.compactMap { peerID, state in + state == .connected ? peerID : nil + } + ) + } + + var pendingRelayCount: Int { + relayQueue.count + } + + /// UI-test-only hook: seeds `count` deterministic fake peer IDs as `.connected` + /// in `peerStates` and invokes `onPeerConnectionStateChanged` for each so + /// `MainViewModel` observers (via `refreshConnectionState` / the per-peer + /// callback) pick up `isConnected == true` without requiring real BLE. + /// + /// Activated by `AppNetworkCoordinator` when the app is launched with + /// `--ui-test-mesh-peers=`. Production code paths never call this. + func seedUITestConnectedPeers(count: Int) { + guard count > 0 else { return } + for index in 0.. 0 else { + return + } + + guard !deduplicator.isDuplicate(messageId: inboundMessage.id) else { + return + } + + inboundMessage.ttl -= 1 + onMessageReceived?(inboundMessage) + + guard inboundMessage.ttl > 0 else { + return + } + + flood(inboundMessage, excluding: sourcePeerID) + } + + private func flood(_ message: Message, excluding excludedPeerID: UUID?) { + var targetPeerIDs = connectedPeerIDs + if let excludedPeerID { + targetPeerIDs.remove(excludedPeerID) + } + + guard !targetPeerIDs.isEmpty else { + relayQueue.append(QueuedRelay(message: message, excludedPeerID: excludedPeerID)) + return + } + + send(message, to: targetPeerIDs) + } + + private func flushRelayQueue(to peerID: UUID) { + guard connectionState(for: peerID) == .connected else { + return + } + + var remaining: [QueuedRelay] = [] + + for item in relayQueue { + if item.excludedPeerID == peerID { + remaining.append(item) + continue + } + + send(item.message, to: [peerID]) + } + + relayQueue = remaining + } + + private func send(_ message: Message, to peerIDs: Set) { + guard let encodedMessage = try? encoder.encode(message) else { + return + } + + let outboundPayload: Data + do { + outboundPayload = try encryptionService.encryptTransportPayload(encodedMessage) + } catch { + return + } + + transport.send(outboundPayload, messageType: message.type, to: peerIDs) + } +} + +final class CoreBluetoothMeshTransport: NSObject, BluetoothMeshTransporting { + var eventHandler: ((BluetoothMeshTransportEvent) -> Void)? + + private var centralManager: CBCentralManager? + private var peripheralManager: CBPeripheralManager? + + private var discoveredPeripherals: [UUID: CBPeripheral] = [:] + private var connectedPeripherals: [UUID: CBPeripheral] = [:] + private var connectingPeripheralIDs: Set = [] + private var discoveredCharacteristicsByPeer: [UUID: [BluetoothMeshCharacteristicKind: CBCharacteristic]] = [:] + private var subscribedCentrals: [UUID: CBCentral] = [:] + + private var hasPublishedService = false + private var treeConfigPayload: Data = Data() + private var advertisedNetworkSummary: NetworkAdvertisement? + private var pendingTreeConfigReadCompletions: [UUID: [(Result) -> Void]] = [:] + + private lazy var broadcastCharacteristic: CBMutableCharacteristic = { + CBMutableCharacteristic( + type: BluetoothMeshUUIDs.broadcastCharacteristic, + properties: [.read, .write, .writeWithoutResponse, .notify], + value: nil, + permissions: [.readable, .writeable] + ) + }() + + private lazy var compactionCharacteristic: CBMutableCharacteristic = { + CBMutableCharacteristic( + type: BluetoothMeshUUIDs.compactionCharacteristic, + properties: [.read, .write, .writeWithoutResponse, .notify], + value: nil, + permissions: [.readable, .writeable] + ) + }() + + private lazy var treeConfigCharacteristic: CBMutableCharacteristic = { + CBMutableCharacteristic( + type: BluetoothMeshUUIDs.treeConfigCharacteristic, + properties: [.read], + value: nil, + permissions: [.readable] + ) + }() + + func start() { + NSLog("[BLE] start() — central: %@, peripheral: %@", + centralManager == nil ? "nil" : centralManager!.state.logDescription, + peripheralManager == nil ? "nil" : peripheralManager!.state.logDescription) + + if centralManager == nil { + centralManager = CBCentralManager(delegate: self, queue: nil) + } + if peripheralManager == nil { + peripheralManager = CBPeripheralManager(delegate: self, queue: nil) + } + + if centralManager?.state == .poweredOn { + startScanning() + } + if peripheralManager?.state == .poweredOn { + publishServiceIfNeeded() + startAdvertising() + } + } + + func stop() { + NSLog("[BLE] stop()") + centralManager?.stopScan() + for peripheral in connectedPeripherals.values { + centralManager?.cancelPeripheralConnection(peripheral) + } + peripheralManager?.stopAdvertising() + + for peerID in Array(pendingTreeConfigReadCompletions.keys) { + completeTreeConfigReads( + for: peerID, + result: .failure(BluetoothMeshTransportError.treeConfigUnavailable) + ) + } + + discoveredPeripherals.removeAll() + connectingPeripheralIDs.removeAll() + connectedPeripherals.removeAll() + discoveredCharacteristicsByPeer.removeAll() + subscribedCentrals.removeAll() + } + + func send(_ data: Data, messageType: Message.MessageType, to peerIDs: Set) { + let characteristicKind = characteristicKind(for: messageType) + + for peerID in peerIDs { + if let peripheral = connectedPeripherals[peerID], + let characteristic = discoveredCharacteristicsByPeer[peerID]?[characteristicKind] { + peripheral.writeValue(data, for: characteristic, type: .withoutResponse) + continue + } + + if let central = subscribedCentrals[peerID], + let peripheralManager { + let localCharacteristic = localCharacteristic(for: characteristicKind) + _ = peripheralManager.updateValue(data, for: localCharacteristic, onSubscribedCentrals: [central]) + } + } + } + + func configureAdvertisement(_ summary: NetworkAdvertisement?) { + advertisedNetworkSummary = summary + guard peripheralManager?.state == .poweredOn else { + return + } + startAdvertising() + } + + func updateTreeConfigPayload(_ data: Data) { + treeConfigPayload = data + } + + func requestTreeConfig(from peerID: UUID, completion: @escaping (Result) -> Void) { + NSLog("[Join] requestTreeConfig — peerID: %@, connected: %@, characteristics known: %@", + peerID.uuidString, + connectedPeripherals[peerID] != nil ? "yes" : "no", + discoveredCharacteristicsByPeer[peerID] != nil ? "yes" : "no") + + pendingTreeConfigReadCompletions[peerID, default: []].append(completion) + + // Timeout: if the GATT read hasn't completed in 10s, fail the join. + DispatchQueue.main.asyncAfter(deadline: .now() + 10) { [weak self] in + guard let self, + self.pendingTreeConfigReadCompletions[peerID] != nil else { return } + NSLog("[Join] ⏱ requestTreeConfig timed out for peer %@", peerID.uuidString) + self.completeTreeConfigReads( + for: peerID, + result: .failure(BluetoothMeshTransportError.treeConfigUnavailable) + ) + } + + if centralManager == nil || peripheralManager == nil { + NSLog("[Join] BLE stack not started — calling start()") + start() + } + + guard let peripheral = connectedPeripherals[peerID] ?? discoveredPeripherals[peerID] else { + NSLog("[Join] ❌ Peer %@ not in discovered or connected set — failing immediately", peerID.uuidString) + completeTreeConfigReads(for: peerID, result: .failure(BluetoothMeshTransportError.unknownPeer)) + return + } + + peripheral.delegate = self + + if let characteristic = discoveredCharacteristicsByPeer[peerID]?[.treeConfig] { + NSLog("[Join] Characteristics already known — reading treeConfig now") + peripheral.readValue(for: characteristic) + return + } + + // Not yet connected or characteristics not yet discovered — queue the read. + // didConnect → discoverServices → didDiscoverCharacteristics → attemptTreeConfigRead + // will resume the completion when ready. + if connectedPeripherals[peerID] != nil { + NSLog("[Join] Connected but no characteristics yet — triggering service discovery") + peripheral.discoverServices([BluetoothMeshUUIDs.service]) + } else if let centralManager, centralManager.state == .poweredOn, + !connectingPeripheralIDs.contains(peerID) { + NSLog("[Join] Not connected — initiating connection to peer %@", peerID.uuidString) + connectingPeripheralIDs.insert(peerID) + centralManager.connect(peripheral, options: nil) + } else { + NSLog("[Join] Waiting for in-progress connection to peer %@ (state: %@)", + peerID.uuidString, centralManager?.state.logDescription ?? "nil") + } + } + + private func startScanning() { + NSLog("[BLE] Scanning for TacNet peripherals…") + centralManager?.scanForPeripherals( + withServices: [BluetoothMeshUUIDs.service], + options: [CBCentralManagerScanOptionAllowDuplicatesKey: false] + ) + } + + private func startAdvertising() { + guard let peripheralManager else { + return + } + + peripheralManager.stopAdvertising() + if let advertisedNetworkSummary { + NSLog("[BLE] Advertising network '%@' (slots: %d, PIN: %@)", + advertisedNetworkSummary.networkName, advertisedNetworkSummary.openSlotCount, + advertisedNetworkSummary.requiresPIN ? "yes" : "no") + peripheralManager.startAdvertising(NetworkAdvertisementCodec.advertisingData(for: advertisedNetworkSummary)) + } else { + NSLog("[BLE] Advertising (no network — service UUID only)") + peripheralManager.startAdvertising([ + CBAdvertisementDataServiceUUIDsKey: [BluetoothMeshUUIDs.service] + ]) + } + } + + private func publishServiceIfNeeded() { + guard !hasPublishedService else { + return + } + + let service = CBMutableService(type: BluetoothMeshUUIDs.service, primary: true) + service.characteristics = [ + broadcastCharacteristic, + compactionCharacteristic, + treeConfigCharacteristic + ] + + peripheralManager?.add(service) + hasPublishedService = true + } + + private func characteristicKind(for messageType: Message.MessageType) -> BluetoothMeshCharacteristicKind { + switch messageType { + case .compaction: + return .compaction + default: + return .broadcast + } + } + + private func localCharacteristic(for kind: BluetoothMeshCharacteristicKind) -> CBMutableCharacteristic { + switch kind { + case .broadcast: + return broadcastCharacteristic + case .compaction: + return compactionCharacteristic + case .treeConfig: + return treeConfigCharacteristic + } + } + + private func attemptTreeConfigRead(for peerID: UUID) { + let hasPending = pendingTreeConfigReadCompletions[peerID] != nil + let isConnected = connectedPeripherals[peerID] != nil + let hasChar = discoveredCharacteristicsByPeer[peerID]?[.treeConfig] != nil + + NSLog("[Join] attemptTreeConfigRead for %@ — pending: %@, connected: %@, hasChar: %@", + peerID.uuidString, + hasPending ? "yes" : "no", + isConnected ? "yes" : "no", + hasChar ? "yes" : "no") + + guard hasPending, + let peripheral = connectedPeripherals[peerID], + let treeConfigCharacteristic = discoveredCharacteristicsByPeer[peerID]?[.treeConfig] else { + return + } + + NSLog("[Join] Issuing GATT read for treeConfig on peer %@", peerID.uuidString) + peripheral.readValue(for: treeConfigCharacteristic) + } + + private func completeTreeConfigReads(for peerID: UUID, result: Result) { + guard let completions = pendingTreeConfigReadCompletions.removeValue(forKey: peerID) else { + return + } + + completions.forEach { completion in + completion(result) + } + } +} + +extension CoreBluetoothMeshTransport: CBCentralManagerDelegate { + func centralManagerDidUpdateState(_ central: CBCentralManager) { + NSLog("[BLE] Central state → %@", central.state.logDescription) + guard central.state == .poweredOn else { + return + } + startScanning() + } + + func centralManager( + _ central: CBCentralManager, + didDiscover peripheral: CBPeripheral, + advertisementData: [String: Any], + rssi RSSI: NSNumber + ) { + let name = peripheral.name ?? "unnamed" + discoveredPeripherals[peripheral.identifier] = peripheral + eventHandler?(.discoveredPeer(peripheral.identifier)) + if let advertisement = NetworkAdvertisementCodec.decode(advertisementData: advertisementData, peerID: peripheral.identifier) { + NSLog("[BLE] Discovered network '%@' from peer %@ (slots: %d, PIN: %@)", + advertisement.networkName, peripheral.identifier.uuidString, + advertisement.openSlotCount, advertisement.requiresPIN ? "yes" : "no") + eventHandler?(.discoveredNetwork(peripheral.identifier, advertisement)) + } else { + NSLog("[BLE] Discovered peer %@ ('%@') — no TacNet advertisement", peripheral.identifier.uuidString, name) + } + + if connectedPeripherals[peripheral.identifier] == nil, + !connectingPeripheralIDs.contains(peripheral.identifier) { + NSLog("[BLE] Connecting to peer %@…", peripheral.identifier.uuidString) + connectingPeripheralIDs.insert(peripheral.identifier) + central.connect(peripheral, options: nil) + } + } + + func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { + NSLog("[BLE] ✅ Connected to peer %@", peripheral.identifier.uuidString) + connectingPeripheralIDs.remove(peripheral.identifier) + connectedPeripherals[peripheral.identifier] = peripheral + eventHandler?(.connectionStateChanged(peripheral.identifier, .connected)) + + peripheral.delegate = self + peripheral.discoverServices([BluetoothMeshUUIDs.service]) + attemptTreeConfigRead(for: peripheral.identifier) + } + + func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { + NSLog("[BLE] ❌ Failed to connect to peer %@: %@", + peripheral.identifier.uuidString, error?.localizedDescription ?? "unknown") + connectingPeripheralIDs.remove(peripheral.identifier) + eventHandler?(.connectionStateChanged(peripheral.identifier, .disconnected)) + completeTreeConfigReads( + for: peripheral.identifier, + result: .failure(error ?? BluetoothMeshTransportError.treeConfigUnavailable) + ) + } + + func centralManager( + _ central: CBCentralManager, + didDisconnectPeripheral peripheral: CBPeripheral, + error: Error? + ) { + NSLog("[BLE] Peer %@ disconnected%@", peripheral.identifier.uuidString, + error != nil ? " (error: \(error!.localizedDescription))" : "") + connectingPeripheralIDs.remove(peripheral.identifier) + connectedPeripherals.removeValue(forKey: peripheral.identifier) + discoveredCharacteristicsByPeer.removeValue(forKey: peripheral.identifier) + eventHandler?(.connectionStateChanged(peripheral.identifier, .disconnected)) + completeTreeConfigReads( + for: peripheral.identifier, + result: .failure(error ?? BluetoothMeshTransportError.treeConfigUnavailable) + ) + } +} + +extension CoreBluetoothMeshTransport: CBPeripheralDelegate { + func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { + if let error { + NSLog("[Join] ❌ didDiscoverServices error for peer %@: %@", + peripheral.identifier.uuidString, error.localizedDescription) + completeTreeConfigReads(for: peripheral.identifier, result: .failure(error)) + return + } + + let serviceCount = peripheral.services?.count ?? 0 + let hasTacNetService = peripheral.services?.contains(where: { $0.uuid == BluetoothMeshUUIDs.service }) ?? false + NSLog("[Join] didDiscoverServices for peer %@ — %d service(s), TacNet service found: %@", + peripheral.identifier.uuidString, serviceCount, hasTacNetService ? "yes" : "no") + + peripheral.services? + .filter { $0.uuid == BluetoothMeshUUIDs.service } + .forEach { service in + peripheral.discoverCharacteristics(BluetoothMeshCharacteristicKind.allCases.map(\.uuid), for: service) + } + } + + func peripheral( + _ peripheral: CBPeripheral, + didDiscoverCharacteristicsFor service: CBService, + error: Error? + ) { + if let error { + NSLog("[Join] ❌ didDiscoverCharacteristics error for peer %@: %@", + peripheral.identifier.uuidString, error.localizedDescription) + completeTreeConfigReads(for: peripheral.identifier, result: .failure(error)) + return + } + + var map: [BluetoothMeshCharacteristicKind: CBCharacteristic] = [:] + + for characteristic in service.characteristics ?? [] { + switch characteristic.uuid { + case BluetoothMeshUUIDs.broadcastCharacteristic: + map[.broadcast] = characteristic + case BluetoothMeshUUIDs.compactionCharacteristic: + map[.compaction] = characteristic + case BluetoothMeshUUIDs.treeConfigCharacteristic: + map[.treeConfig] = characteristic + default: + break + } + + if characteristic.properties.contains(.notify) { + peripheral.setNotifyValue(true, for: characteristic) + } + } + + NSLog("[Join] didDiscoverCharacteristics for peer %@ — broadcast: %@, compaction: %@, treeConfig: %@", + peripheral.identifier.uuidString, + map[.broadcast] != nil ? "✅" : "❌", + map[.compaction] != nil ? "✅" : "❌", + map[.treeConfig] != nil ? "✅" : "❌") + + discoveredCharacteristicsByPeer[peripheral.identifier] = map + attemptTreeConfigRead(for: peripheral.identifier) + } + + func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { + if characteristic.uuid == BluetoothMeshUUIDs.treeConfigCharacteristic, + pendingTreeConfigReadCompletions[peripheral.identifier] != nil { + if let error { + NSLog("[Join] ❌ treeConfig read error for peer %@: %@", + peripheral.identifier.uuidString, error.localizedDescription) + completeTreeConfigReads(for: peripheral.identifier, result: .failure(error)) + } else if let value = characteristic.value { + NSLog("[Join] ✅ treeConfig read success for peer %@ — %d bytes", + peripheral.identifier.uuidString, value.count) + completeTreeConfigReads(for: peripheral.identifier, result: .success(value)) + } else { + NSLog("[Join] ❌ treeConfig read returned nil value for peer %@", + peripheral.identifier.uuidString) + completeTreeConfigReads( + for: peripheral.identifier, + result: .failure(BluetoothMeshTransportError.treeConfigUnavailable) + ) + } + return + } + + guard error == nil, let value = characteristic.value else { + return + } + + eventHandler?(.receivedData(value, from: peripheral.identifier)) + } +} + +extension CoreBluetoothMeshTransport: CBPeripheralManagerDelegate { + func peripheralManagerDidUpdateState(_ peripheral: CBPeripheralManager) { + NSLog("[BLE] Peripheral state → %@", peripheral.state.logDescription) + guard peripheral.state == .poweredOn else { + return + } + + publishServiceIfNeeded() + startAdvertising() + } + + func peripheralManager(_ peripheral: CBPeripheralManager, didAdd service: CBService, error: Error?) { + if let error { + NSLog("[BLE] ❌ Failed to add GATT service: %@", error.localizedDescription) + return + } + NSLog("[BLE] ✅ GATT service published") + startAdvertising() + } + + func peripheralManager(_ peripheral: CBPeripheralManager, didReceiveRead request: CBATTRequest) { + NSLog("[Join] Received GATT read request from central %@ — characteristic: %@, offset: %d, payload size: %d", + request.central.identifier.uuidString, + request.characteristic.uuid.uuidString, + request.offset, + treeConfigPayload.count) + + guard request.characteristic.uuid == BluetoothMeshUUIDs.treeConfigCharacteristic else { + NSLog("[Join] ❌ Read on unsupported characteristic — rejecting") + peripheral.respond(to: request, withResult: .requestNotSupported) + return + } + + guard request.offset <= treeConfigPayload.count else { + NSLog("[Join] ❌ Invalid offset %d (payload is %d bytes)", request.offset, treeConfigPayload.count) + peripheral.respond(to: request, withResult: .invalidOffset) + return + } + + if treeConfigPayload.isEmpty { + NSLog("[Join] ⚠️ treeConfigPayload is empty — joiner will get no data") + } else { + NSLog("[Join] ✅ Serving %d bytes of treeConfig (offset %d)", + treeConfigPayload.count - request.offset, request.offset) + } + + request.value = treeConfigPayload.subdata(in: request.offset..] = [:] + private let defaultTTL = 8 + + init( + meshService: BluetoothMeshService = BluetoothMeshService(), + configStore: NetworkConfigStore? = nil, + disconnectTimeout: TimeInterval = 60 + ) { + self.meshService = meshService + self.configStore = configStore + self.disconnectTimeout = max(0, disconnectTimeout) + self.localConfig = configStore?.load() + } + + deinit { + disconnectReparentTasks.values.forEach { $0.cancel() } + } + + func setLocalConfig(_ config: NetworkConfig?) { + localConfig = config + persistLocalConfig(config) + + if config == nil { + disconnectReparentTasks.values.forEach { $0.cancel() } + disconnectReparentTasks.removeAll() + meshService.clearSessionKey() + } + } + + func handlePeerStateChange(peerID: UUID, state: PeerConnectionState) { + switch state { + case .connected: + cancelDisconnectTask(for: peerID) + case .disconnected: + scheduleDisconnectAutoReparent(for: peerID) + } + } + + func secureConfigForPublishing(_ config: NetworkConfig) -> NetworkConfig { + var securedConfig = config + let keyMaterial = NetworkEncryptionService.keyMaterial( + pinHash: securedConfig.pinHash, + networkID: securedConfig.networkID + ) + + if let existingConfig = localConfig, + existingConfig.networkID == securedConfig.networkID, + existingConfig.pinHash == securedConfig.pinHash, + let existingWrappedSessionKey = existingConfig.encryptedSessionKey { + securedConfig.encryptedSessionKey = existingWrappedSessionKey + + if !meshService.hasActiveSessionKey(for: securedConfig.networkID) { + let didActivate = (try? meshService.activateSessionKey( + networkID: securedConfig.networkID, + wrappedSessionKey: existingWrappedSessionKey, + keyMaterial: keyMaterial + )) != nil + + if !didActivate, + let regeneratedWrappedKey = try? meshService.prepareSessionKeyForPublishing( + networkID: securedConfig.networkID, + keyMaterial: keyMaterial + ) { + securedConfig.encryptedSessionKey = regeneratedWrappedKey + } else if !didActivate { + meshService.activateDeterministicSessionKey( + networkID: securedConfig.networkID, + keyMaterial: keyMaterial + ) + securedConfig.encryptedSessionKey = nil + } + } + return securedConfig + } + + if let suppliedWrappedSessionKey = securedConfig.encryptedSessionKey { + let didActivate = (try? meshService.activateSessionKey( + networkID: securedConfig.networkID, + wrappedSessionKey: suppliedWrappedSessionKey, + keyMaterial: keyMaterial + )) != nil + if didActivate { + return securedConfig + } + } + + if let wrappedSessionKey = try? meshService.prepareSessionKeyForPublishing( + networkID: securedConfig.networkID, + keyMaterial: keyMaterial + ) { + securedConfig.encryptedSessionKey = wrappedSessionKey + } else { + meshService.activateDeterministicSessionKey( + networkID: securedConfig.networkID, + keyMaterial: keyMaterial + ) + securedConfig.encryptedSessionKey = nil + } + + return securedConfig + } + + @discardableResult + func converge(with incoming: NetworkConfig) -> TreeSyncConvergenceResult { + guard let localConfig else { + self.localConfig = incoming + persistLocalConfig(incoming) + return .adoptedInitial(version: incoming.version) + } + + guard localConfig.networkID == incoming.networkID else { + return .ignoredDifferentNetwork( + expectedNetworkID: localConfig.networkID, + incomingNetworkID: incoming.networkID + ) + } + + guard incoming.version > localConfig.version else { + return .ignoredStale(localVersion: localConfig.version, incomingVersion: incoming.version) + } + + var mergedIncoming = incoming + mergedIncoming.tree = Self.treeByPreservingClaims( + from: localConfig.tree, + into: incoming.tree + ) + self.localConfig = mergedIncoming + persistLocalConfig(mergedIncoming) + return .replacedWithHigherVersion( + previousVersion: localConfig.version, + appliedVersion: mergedIncoming.version + ) + } + + @discardableResult + func converge(with payload: Data) throws -> TreeSyncConvergenceResult { + let incoming = try JSONDecoder().decode(NetworkConfig.self, from: payload) + return converge(with: incoming) + } + + func join(network: DiscoveredNetwork, pin: String?) async throws -> NetworkConfig { + NSLog("[Join] TreeSyncService.join — fetching config from peer %@", network.peerID.uuidString) + let remoteConfig: NetworkConfig + do { + remoteConfig = try await meshService.fetchNetworkConfig(from: network.peerID) + NSLog("[Join] Received config — networkID: %@, version: %d, requiresPIN: %@", + remoteConfig.networkID.uuidString, remoteConfig.version, + remoteConfig.requiresPIN ? "yes" : "no") + } catch { + NSLog("[Join] ❌ fetchNetworkConfig failed: %@", error.localizedDescription) + throw TreeSyncJoinError.treeConfigUnavailable + } + + // NOTE: network.networkID is a proxy (== peerID) because BLE advertisements cannot + // carry the real networkID — only LocalName and ServiceUUIDs are allowed by CoreBluetooth. + // The real networkID comes from the GATT treeConfig read we just completed. + // We do NOT compare them; remoteConfig.networkID is the authoritative value. + NSLog("[Join] Using real networkID from GATT config: %@ (peer BT ID was: %@)", + remoteConfig.networkID.uuidString, network.peerID.uuidString) + + if remoteConfig.requiresPIN { + guard let pin, !pin.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + NSLog("[Join] ❌ PIN required but not provided") + throw TreeSyncJoinError.pinRequired + } + + guard remoteConfig.isValidPIN(pin) else { + NSLog("[Join] ❌ PIN validation failed") + throw TreeSyncJoinError.invalidPIN + } + NSLog("[Join] PIN validated ✅") + } + + let keyMaterial = NetworkEncryptionService.keyMaterial( + pinHash: remoteConfig.pinHash ?? NetworkConfig.hashPIN(pin), + networkID: remoteConfig.networkID + ) + + if let wrappedSessionKey = remoteConfig.encryptedSessionKey { + do { + try meshService.activateSessionKey( + networkID: remoteConfig.networkID, + wrappedSessionKey: wrappedSessionKey, + keyMaterial: keyMaterial + ) + NSLog("[Join] Session key activated from wrapped key ✅") + } catch { + NSLog("[Join] ❌ Failed to activate session key: %@", error.localizedDescription) + if remoteConfig.requiresPIN { + throw TreeSyncJoinError.invalidPIN + } + throw TreeSyncJoinError.treeConfigUnavailable + } + } else { + meshService.activateDeterministicSessionKey( + networkID: remoteConfig.networkID, + keyMaterial: keyMaterial + ) + NSLog("[Join] Deterministic session key activated ✅") + } + + localConfig = remoteConfig + persistLocalConfig(remoteConfig) + NSLog("[Join] ✅ TreeSyncService.join complete — '%@' v%d", remoteConfig.networkName, remoteConfig.version) + return remoteConfig + } + + private func scheduleDisconnectAutoReparent(for peerID: UUID) { + let disconnectedOwnerID = peerID.uuidString + guard let localConfig, + !Self.claimedNodeIDs(by: disconnectedOwnerID, in: localConfig.tree).isEmpty else { + return + } + + cancelDisconnectTask(for: peerID) + disconnectReparentTasks[peerID] = Task { [weak self] in + guard let self else { + return + } + + let timeoutNanoseconds = UInt64(self.disconnectTimeout * 1_000_000_000) + try? await Task.sleep(nanoseconds: timeoutNanoseconds) + guard !Task.isCancelled else { + return + } + self.performDisconnectAutoReparentIfNeeded(for: peerID) + } + } + + private func cancelDisconnectTask(for peerID: UUID) { + disconnectReparentTasks.removeValue(forKey: peerID)?.cancel() + } + + private func performDisconnectAutoReparentIfNeeded(for peerID: UUID) { + disconnectReparentTasks[peerID] = nil + + guard meshService.connectionState(for: peerID) == .disconnected, + var config = localConfig else { + return + } + + let disconnectedOwnerID = peerID.uuidString + let disconnectedNodeIDs = Self.claimedNodeIDs(by: disconnectedOwnerID, in: config.tree) + guard !disconnectedNodeIDs.isEmpty else { + return + } + + var didMutate = false + + for disconnectedNodeID in disconnectedNodeIDs { + guard let targetAncestorID = nearestConnectedAncestor( + above: disconnectedNodeID, + in: config.tree + ) else { + continue + } + + let childNodeIDs = Self.childNodeIDs(of: disconnectedNodeID, in: config.tree) + guard !childNodeIDs.isEmpty else { + continue + } + + for childNodeID in childNodeIDs { + guard Self.moveNode( + nodeID: childNodeID, + newParentID: targetAncestorID, + in: &config.tree + ) else { + continue + } + + config.version += 1 + didMutate = true + publishTreeUpdate(changedNodeID: childNodeID, in: config) + } + } + + guard didMutate else { + return + } + + setLocalConfig(config) + } + + private func nearestConnectedAncestor(above nodeID: String, in tree: TreeNode) -> String? { + var candidateAncestorID = TreeHelpers.parent(of: nodeID, in: tree)?.id + + while let ancestorID = candidateAncestorID { + guard let ancestorNode = Self.findNode(withID: ancestorID, in: tree) else { + return nil + } + + if isConnected(node: ancestorNode) { + return ancestorID + } + + candidateAncestorID = TreeHelpers.parent(of: ancestorID, in: tree)?.id + } + + return nil + } + + private func isConnected(node: TreeNode) -> Bool { + guard let ownerID = node.claimedBy?.trimmingCharacters(in: .whitespacesAndNewlines), + !ownerID.isEmpty else { + return true + } + + guard let peerID = UUID(uuidString: ownerID) else { + return true + } + + return meshService.connectionState(for: peerID) == .connected + } + + private func publishTreeUpdate(changedNodeID: String, in config: NetworkConfig) { + let parentID = TreeHelpers.parent(of: changedNodeID, in: config.tree)?.id + let treeLevel = TreeHelpers.level(of: changedNodeID, in: config.tree) ?? 0 + let treeUpdate = Message.make( + type: .treeUpdate, + senderID: config.createdBy, + senderRole: "organiser", + parentID: parentID, + treeLevel: treeLevel, + ttl: defaultTTL, + encrypted: false, + latitude: nil, + longitude: nil, + accuracy: nil, + tree: config.tree, + networkVersion: config.version + ) + meshService.publish(treeUpdate) + } + + private func persistLocalConfig(_ config: NetworkConfig?) { + guard let configStore else { + return + } + + if let config { + try? configStore.save(config) + } else { + configStore.clear() + } + } + + private static func treeByPreservingClaims(from localTree: TreeNode, into incomingTree: TreeNode) -> TreeNode { + var mergedTree = incomingTree + let localClaims = claimedNodeMap(in: localTree) + applyClaims(localClaims, to: &mergedTree) + return mergedTree + } + + private static func claimedNodeMap(in tree: TreeNode) -> [String: String] { + var claims: [String: String] = [:] + collectClaims(in: tree, into: &claims) + return claims + } + + private static func collectClaims(in tree: TreeNode, into claims: inout [String: String]) { + if let owner = tree.claimedBy, !owner.isEmpty { + claims[tree.id] = owner + } + + for child in tree.children { + collectClaims(in: child, into: &claims) + } + } + + private static func applyClaims(_ localClaims: [String: String], to tree: inout TreeNode) { + if (tree.claimedBy == nil || tree.claimedBy?.isEmpty == true), + let preservedClaim = localClaims[tree.id], + !preservedClaim.isEmpty { + tree.claimedBy = preservedClaim + } + + for index in tree.children.indices { + applyClaims(localClaims, to: &tree.children[index]) + } + } + + private static func claimedNodeIDs(by ownerID: String, in tree: TreeNode) -> [String] { + var claimedNodeIDs: [String] = [] + collectClaimedNodeIDs(by: ownerID, in: tree, into: &claimedNodeIDs) + return claimedNodeIDs + } + + private static func collectClaimedNodeIDs( + by ownerID: String, + in tree: TreeNode, + into collection: inout [String] + ) { + if tree.claimedBy == ownerID { + collection.append(tree.id) + } + + for child in tree.children { + collectClaimedNodeIDs(by: ownerID, in: child, into: &collection) + } + } + + private static func childNodeIDs(of parentNodeID: String, in tree: TreeNode) -> [String] { + findNode(withID: parentNodeID, in: tree)?.children.map(\.id) ?? [] + } + + private static func findNode(withID nodeID: String, in tree: TreeNode) -> TreeNode? { + if tree.id == nodeID { + return tree + } + + for child in tree.children { + if let found = findNode(withID: nodeID, in: child) { + return found + } + } + + return nil + } + + private static func moveNode(nodeID: String, newParentID: String, in tree: inout TreeNode) -> Bool { + guard nodeID != tree.id, nodeID != newParentID else { + return false + } + + guard let nodeToMove = findNode(withID: nodeID, in: tree), + findNode(withID: newParentID, in: tree) != nil, + !treeContainsNode(withID: newParentID, in: nodeToMove) else { + return false + } + + let originalParentID = TreeHelpers.parent(of: nodeID, in: tree)?.id + guard let detachedNode = detachNode(nodeID: nodeID, from: &tree) else { + return false + } + + guard appendChild(detachedNode, toParentID: newParentID, in: &tree) else { + if let originalParentID { + _ = appendChild(detachedNode, toParentID: originalParentID, in: &tree) + } + return false + } + + return true + } + + private static func detachNode(nodeID: String, from tree: inout TreeNode) -> TreeNode? { + if let index = tree.children.firstIndex(where: { $0.id == nodeID }) { + return tree.children.remove(at: index) + } + + for index in tree.children.indices { + if let detachedNode = detachNode(nodeID: nodeID, from: &tree.children[index]) { + return detachedNode + } + } + + return nil + } + + private static func appendChild(_ child: TreeNode, toParentID parentID: String, in tree: inout TreeNode) -> Bool { + if tree.id == parentID { + tree.children.append(child) + return true + } + + for index in tree.children.indices { + if appendChild(child, toParentID: parentID, in: &tree.children[index]) { + return true + } + } + + return false + } + + private static func treeContainsNode(withID nodeID: String, in tree: TreeNode) -> Bool { + if tree.id == nodeID { + return true + } + + for child in tree.children { + if treeContainsNode(withID: nodeID, in: child) { + return true + } + } + + return false + } +} + +@MainActor +final class NetworkDiscoveryService: ObservableObject { + @Published private(set) var nearbyNetworks: [DiscoveredNetwork] = [] + @Published private(set) var isScanning = false + + private let meshService: BluetoothMeshService + private var scanTimeoutTask: Task? + + init(meshService: BluetoothMeshService = BluetoothMeshService()) { + self.meshService = meshService + } + + deinit { + scanTimeoutTask?.cancel() + } + + func startScanning(timeout: TimeInterval = 10) { + NSLog("[Discovery] startScanning — timeout: %.0fs", timeout) + nearbyNetworks = [] + isScanning = true + + meshService.onNetworkDiscovered = { [weak self] peerID, summary in + Task { @MainActor in + NSLog("[Discovery] Network discovered — '%@' from peer %@ (slots: %d, PIN: %@)", + summary.networkName, peerID.uuidString, summary.openSlotCount, + summary.requiresPIN ? "yes" : "no") + self?.upsert( + DiscoveredNetwork( + peerID: peerID, + networkID: summary.networkID, + networkName: summary.networkName, + openSlotCount: summary.openSlotCount, + requiresPIN: summary.requiresPIN + ) + ) + } + } + + meshService.start() + scanTimeoutTask?.cancel() + + let timeoutNanoseconds = UInt64(max(timeout, 0) * 1_000_000_000) + scanTimeoutTask = Task { [weak self] in + try? await Task.sleep(nanoseconds: timeoutNanoseconds) + guard !Task.isCancelled else { + return + } + await MainActor.run { + self?.isScanning = false + } + } + } + + func stopScanning() { + isScanning = false + scanTimeoutTask?.cancel() + scanTimeoutTask = nil + meshService.onNetworkDiscovered = nil + } + + private func upsert(_ network: DiscoveredNetwork) { + if let index = nearbyNetworks.firstIndex(where: { $0.peerID == network.peerID }) { + nearbyNetworks[index] = network + } else { + nearbyNetworks.append(network) + } + + nearbyNetworks.sort { lhs, rhs in + if lhs.openSlotCount == rhs.openSlotCount { + return lhs.networkName.localizedCaseInsensitiveCompare(rhs.networkName) == .orderedAscending + } + return lhs.openSlotCount > rhs.openSlotCount + } + } +} + +enum ClaimRejectionReason: String, Codable, Equatable, Sendable { + case alreadyClaimed = "already_claimed" + case organiserWins = "organiser_wins" + case nodeNotFound = "node_not_found" +} + +enum RoleClaimResult: Equatable, Sendable { + case claimed(nodeID: String) + case released(nodeID: String) + case rejected(reason: ClaimRejectionReason) + case noActiveClaim + case unavailable +} + +enum PromoteValidationError: Error, Equatable, Sendable { + case networkUnavailable + case nodeNotFound + case targetUnclaimed +} + +@MainActor +final class RoleClaimService: ObservableObject { + @Published private(set) var activeClaimNodeID: String? + @Published private(set) var lastClaimRejection: ClaimRejectionReason? + @Published private(set) var networkConfig: NetworkConfig? + @Published private(set) var requiresRoleReselection = false + @Published private(set) var roleReselectionNotification: String? + + private let meshService: BluetoothMeshService + private let treeSyncService: TreeSyncService + private let localDeviceID: String + private let disconnectTimeout: TimeInterval + private var disconnectReleaseTasks: [UUID: Task] = [:] + private var cancellables: Set = [] + + private let defaultTTL = 8 + + init( + meshService: BluetoothMeshService, + treeSyncService: TreeSyncService, + localDeviceID: String, + disconnectTimeout: TimeInterval = 60 + ) { + self.meshService = meshService + self.treeSyncService = treeSyncService + self.localDeviceID = localDeviceID + self.disconnectTimeout = max(0, disconnectTimeout) + + treeSyncService.$localConfig + .receive(on: RunLoop.main) + .sink { [weak self] config in + self?.applyLocalConfigSnapshot(config) + } + .store(in: &cancellables) + + applyLocalConfigSnapshot(treeSyncService.localConfig) + } + + deinit { + disconnectReleaseTasks.values.forEach { $0.cancel() } + } + + var localNodeIdentity: String { + localDeviceID + } + + var isOrganiser: Bool { + networkConfig?.createdBy == localDeviceID + } + + func claim(nodeID: String) -> RoleClaimResult { + NSLog("[Role] Attempting to claim node '%@'", nodeID) + guard var config = networkConfig else { + NSLog("[Role] ❌ Claim failed — no network config") + return .unavailable + } + + guard let node = findNode(withID: nodeID, in: config.tree) else { + NSLog("[Role] ❌ Claim failed — node not found") + lastClaimRejection = .nodeNotFound + return .rejected(reason: .nodeNotFound) + } + + if let existingClaim = node.claimedBy, !existingClaim.isEmpty, existingClaim != localDeviceID { + if config.createdBy == localDeviceID { + guard updateClaim(nodeID: nodeID, claimedBy: localDeviceID, in: &config.tree) else { + return .unavailable + } + + NSLog("[Role] ✅ Organiser override — claimed '%@' (was held by %@)", node.label, existingClaim) + applyUpdatedConfig(config) + publishClaim(nodeID: nodeID, claimantID: localDeviceID, in: config) + publishClaimRejected( + nodeID: nodeID, + targetDeviceID: existingClaim, + reason: .organiserWins, + in: config + ) + lastClaimRejection = nil + clearRoleReselectionState() + return .claimed(nodeID: nodeID) + } + + NSLog("[Role] ❌ Claim rejected — node '%@' already claimed by %@", node.label, existingClaim) + lastClaimRejection = .alreadyClaimed + return .rejected(reason: .alreadyClaimed) + } + + guard updateClaim(nodeID: nodeID, claimedBy: localDeviceID, in: &config.tree) else { + return .unavailable + } + + NSLog("[Role] ✅ Claimed node '%@' (%@)", node.label, nodeID) + applyUpdatedConfig(config) + publishClaim(nodeID: nodeID, claimantID: localDeviceID, in: config) + lastClaimRejection = nil + clearRoleReselectionState() + return .claimed(nodeID: nodeID) + } + + func releaseActiveClaim() -> RoleClaimResult { + guard var config = networkConfig else { + return .unavailable + } + + guard let claimedNodeID = activeClaimNodeID ?? firstClaimedNodeID(by: localDeviceID, in: config.tree) else { + return .noActiveClaim + } + + guard updateClaim(nodeID: claimedNodeID, claimedBy: nil, in: &config.tree) else { + return .noActiveClaim + } + + applyUpdatedConfig(config) + publishRelease(nodeID: claimedNodeID, releasedBy: localDeviceID, in: config) + lastClaimRejection = nil + clearRoleReselectionState() + return .released(nodeID: claimedNodeID) + } + + func validatePromoteTarget(nodeID: String) throws { + guard let config = networkConfig else { + throw PromoteValidationError.networkUnavailable + } + + guard let node = findNode(withID: nodeID, in: config.tree) else { + throw PromoteValidationError.nodeNotFound + } + + guard let claimant = node.claimedBy, !claimant.isEmpty else { + throw PromoteValidationError.targetUnclaimed + } + } + + @discardableResult + func addNode(parentID: String, label: String) -> TreeNode? { + guard isOrganiser, var config = networkConfig else { + return nil + } + + let trimmedLabel = label.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedLabel.isEmpty else { + return nil + } + + guard let createdNode = insertChild(parentID: parentID, label: trimmedLabel, in: &config.tree) else { + return nil + } + + config.version += 1 + applyUpdatedConfig(config) + publishTreeUpdate(changedNodeID: createdNode.id, in: config) + return createdNode + } + + @discardableResult + func removeNode(nodeID: String) -> Bool { + guard isOrganiser, var config = networkConfig else { + return false + } + + guard nodeID != config.tree.id else { + return false + } + + guard removeTreeNode(nodeID: nodeID, from: &config.tree) != nil else { + return false + } + + config.version += 1 + applyUpdatedConfig(config) + publishTreeUpdate(changedNodeID: nil, in: config) + return true + } + + @discardableResult + func renameNode(nodeID: String, newLabel: String) -> Bool { + guard isOrganiser, var config = networkConfig else { + return false + } + + let trimmedLabel = newLabel.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedLabel.isEmpty else { + return false + } + + guard renameTreeNode(nodeID: nodeID, newLabel: trimmedLabel, in: &config.tree) else { + return false + } + + config.version += 1 + applyUpdatedConfig(config) + publishTreeUpdate(changedNodeID: nodeID, in: config) + return true + } + + @discardableResult + func moveNode(nodeID: String, newParentID: String) -> Bool { + guard isOrganiser, var config = networkConfig else { + return false + } + + guard nodeID != config.tree.id, nodeID != newParentID else { + return false + } + + guard let nodeToMove = findNode(withID: nodeID, in: config.tree), + findNode(withID: newParentID, in: config.tree) != nil, + !treeContainsNode(withID: newParentID, in: nodeToMove) else { + return false + } + + let originalParentID = TreeHelpers.parent(of: nodeID, in: config.tree)?.id + guard let detachedNode = removeTreeNode(nodeID: nodeID, from: &config.tree) else { + return false + } + + guard appendChild(detachedNode, toParentID: newParentID, in: &config.tree) else { + if let originalParentID { + _ = appendChild(detachedNode, toParentID: originalParentID, in: &config.tree) + } + return false + } + + config.version += 1 + applyUpdatedConfig(config) + publishTreeUpdate(changedNodeID: nodeID, in: config) + return true + } + + @discardableResult + func reorderNode(nodeID: String, beforeSiblingID: String) -> Bool { + guard isOrganiser, var config = networkConfig else { + return false + } + + guard nodeID != beforeSiblingID, + nodeID != config.tree.id, + beforeSiblingID != config.tree.id else { + return false + } + + guard let sourceParentID = TreeHelpers.parent(of: nodeID, in: config.tree)?.id, + let targetParentID = TreeHelpers.parent(of: beforeSiblingID, in: config.tree)?.id, + sourceParentID == targetParentID else { + return false + } + + guard reorderSiblingNode(nodeID: nodeID, beforeSiblingID: beforeSiblingID, in: &config.tree) else { + return false + } + + config.version += 1 + applyUpdatedConfig(config) + publishTreeUpdate(changedNodeID: nodeID, in: config) + return true + } + + @discardableResult + func promote(nodeID: String) -> Bool { + guard isOrganiser, var config = networkConfig else { + return false + } + + guard let targetNode = findNode(withID: nodeID, in: config.tree), + let promotedDeviceID = targetNode.claimedBy, + !promotedDeviceID.isEmpty else { + return false + } + + let senderRoleAtPromotion = senderRole(for: localDeviceID, in: config) + guard config.createdBy != promotedDeviceID else { + return false + } + + config.createdBy = promotedDeviceID + config.version += 1 + applyUpdatedConfig(config) + publishPromote( + nodeID: nodeID, + senderRole: senderRoleAtPromotion, + in: config + ) + return true + } + + func handleIncomingMessage(_ message: Message) { + switch message.type { + case .claim: + handleIncomingClaim(message) + case .release: + handleIncomingRelease(message) + case .treeUpdate: + handleIncomingTreeUpdate(message) + case .promote: + handleIncomingPromote(message) + case .claimRejected: + handleIncomingClaimRejected(message) + default: + break + } + } + + func handlePeerStateChange(peerID: UUID, state: PeerConnectionState) { + switch state { + case .connected: + cancelDisconnectTask(for: peerID) + + case .disconnected: + guard isOrganiser else { + return + } + scheduleDisconnectAutoRelease(for: peerID) + } + } + + private func handleIncomingClaim(_ message: Message) { + guard let nodeID = message.payload.claimedNodeID, var config = networkConfig else { + return + } + + guard let currentNode = findNode(withID: nodeID, in: config.tree) else { + if config.createdBy == localDeviceID { + publishClaimRejected( + nodeID: nodeID, + targetDeviceID: message.senderID, + reason: .nodeNotFound, + in: config + ) + } + return + } + + let senderID = message.senderID + let organiserID = config.createdBy + let existingClaim = currentNode.claimedBy + + if existingClaim == senderID { + return + } + + if existingClaim == nil || existingClaim?.isEmpty == true { + guard updateClaim(nodeID: nodeID, claimedBy: senderID, in: &config.tree) else { + return + } + applyUpdatedConfig(config) + return + } + + guard let existingClaim else { + return + } + + if senderID == organiserID && existingClaim != organiserID { + guard updateClaim(nodeID: nodeID, claimedBy: senderID, in: &config.tree) else { + return + } + applyUpdatedConfig(config) + return + } + + guard localDeviceID == organiserID else { + return + } + + if existingClaim == organiserID { + publishClaimRejected( + nodeID: nodeID, + targetDeviceID: senderID, + reason: .organiserWins, + in: config + ) + return + } + + publishClaimRejected( + nodeID: nodeID, + targetDeviceID: senderID, + reason: .alreadyClaimed, + in: config + ) + } + + private func handleIncomingRelease(_ message: Message) { + guard let nodeID = message.payload.claimedNodeID, var config = networkConfig else { + return + } + + guard let currentNode = findNode(withID: nodeID, in: config.tree), + let currentClaim = currentNode.claimedBy, + !currentClaim.isEmpty else { + return + } + + let senderID = message.senderID + let organiserID = config.createdBy + guard senderID == currentClaim || senderID == organiserID else { + return + } + + guard updateClaim(nodeID: nodeID, claimedBy: nil, in: &config.tree) else { + return + } + applyUpdatedConfig(config) + } + + private func handleIncomingTreeUpdate(_ message: Message) { + guard let incomingTree = message.payload.tree, + let incomingVersion = message.payload.networkVersion, + var incomingConfig = networkConfig else { + return + } + + let previouslyClaimedNodeID = activeClaimNodeID + incomingConfig.tree = incomingTree + incomingConfig.version = incomingVersion + + _ = treeSyncService.converge(with: incomingConfig) + + guard let appliedConfig = treeSyncService.localConfig else { + return + } + + applyLocalConfigSnapshot(appliedConfig) + + guard let previouslyClaimedNodeID, + findNode(withID: previouslyClaimedNodeID, in: appliedConfig.tree) == nil else { + return + } + + activeClaimNodeID = nil + lastClaimRejection = .nodeNotFound + requiresRoleReselection = true + roleReselectionNotification = "Your claimed role was removed from the tree." + } + + private func handleIncomingPromote(_ message: Message) { + guard let targetNodeID = message.payload.targetNodeID, + let promotedVersion = message.payload.networkVersion, + var config = networkConfig, + let targetNode = findNode(withID: targetNodeID, in: config.tree), + let promotedDeviceID = targetNode.claimedBy, + !promotedDeviceID.isEmpty else { + return + } + + let currentVersion = config.version + guard promotedVersion > currentVersion else { + return + } + + config.createdBy = promotedDeviceID + config.version = promotedVersion + applyUpdatedConfig(config) + } + + private func handleIncomingClaimRejected(_ message: Message) { + guard message.payload.targetNodeID == localDeviceID else { + return + } + + if let rawReason = message.payload.rejectionReason, + let reason = ClaimRejectionReason(rawValue: rawReason) { + lastClaimRejection = reason + } else { + lastClaimRejection = .alreadyClaimed + } + + guard let nodeID = message.payload.claimedNodeID, var config = networkConfig else { + return + } + + guard let node = findNode(withID: nodeID, in: config.tree), + node.claimedBy == localDeviceID else { + return + } + + guard updateClaim(nodeID: nodeID, claimedBy: nil, in: &config.tree) else { + return + } + applyUpdatedConfig(config) + } + + private func scheduleDisconnectAutoRelease(for peerID: UUID) { + let disconnectedOwnerID = peerID.uuidString + guard let config = networkConfig, + !claimedNodeIDs(by: disconnectedOwnerID, in: config.tree).isEmpty else { + return + } + + cancelDisconnectTask(for: peerID) + disconnectReleaseTasks[peerID] = Task { [weak self] in + guard let self else { + return + } + + let timeoutNanoseconds = UInt64(self.disconnectTimeout * 1_000_000_000) + try? await Task.sleep(nanoseconds: timeoutNanoseconds) + guard !Task.isCancelled else { + return + } + self.performDisconnectAutoReleaseIfNeeded(for: peerID) + } + } + + private func cancelDisconnectTask(for peerID: UUID) { + disconnectReleaseTasks.removeValue(forKey: peerID)?.cancel() + } + + private func performDisconnectAutoReleaseIfNeeded(for peerID: UUID) { + disconnectReleaseTasks[peerID] = nil + + guard meshService.connectionState(for: peerID) == .disconnected, + var config = networkConfig, + config.createdBy == localDeviceID else { + return + } + + let disconnectedOwnerID = peerID.uuidString + let nodesToRelease = claimedNodeIDs(by: disconnectedOwnerID, in: config.tree) + guard !nodesToRelease.isEmpty else { + return + } + + for nodeID in nodesToRelease { + guard updateClaim(nodeID: nodeID, claimedBy: nil, in: &config.tree) else { + continue + } + publishRelease(nodeID: nodeID, releasedBy: disconnectedOwnerID, in: config) + } + + applyUpdatedConfig(config) + } + + private func publishClaim(nodeID: String, claimantID: String, in config: NetworkConfig) { + let claimMessage = Message.make( + type: .claim, + senderID: claimantID, + senderRole: senderRole(for: claimantID, in: config), + parentID: TreeHelpers.parent(of: nodeID, in: config.tree)?.id, + treeLevel: TreeHelpers.level(of: nodeID, in: config.tree) ?? 0, + ttl: defaultTTL, + encrypted: false, + latitude: nil, + longitude: nil, + accuracy: nil, + claimedNodeID: nodeID + ) + meshService.publish(claimMessage) + } + + private func publishRelease(nodeID: String, releasedBy: String, in config: NetworkConfig) { + let releaseMessage = Message.make( + type: .release, + senderID: releasedBy, + senderRole: senderRole(for: releasedBy, in: config), + parentID: TreeHelpers.parent(of: nodeID, in: config.tree)?.id, + treeLevel: TreeHelpers.level(of: nodeID, in: config.tree) ?? 0, + ttl: defaultTTL, + encrypted: false, + latitude: nil, + longitude: nil, + accuracy: nil, + claimedNodeID: nodeID + ) + meshService.publish(releaseMessage) + } + + private func publishClaimRejected( + nodeID: String, + targetDeviceID: String, + reason: ClaimRejectionReason, + in config: NetworkConfig + ) { + let rejectedMessage = Message.make( + type: .claimRejected, + senderID: localDeviceID, + senderRole: senderRole(for: localDeviceID, in: config), + parentID: TreeHelpers.parent(of: nodeID, in: config.tree)?.id, + treeLevel: TreeHelpers.level(of: nodeID, in: config.tree) ?? 0, + ttl: defaultTTL, + encrypted: false, + latitude: nil, + longitude: nil, + accuracy: nil, + claimedNodeID: nodeID, + targetNodeID: targetDeviceID, + rejectionReason: reason.rawValue + ) + meshService.publish(rejectedMessage) + } + + private func publishTreeUpdate(changedNodeID: String?, in config: NetworkConfig) { + let parentID = changedNodeID.flatMap { TreeHelpers.parent(of: $0, in: config.tree)?.id } + let treeLevel = changedNodeID.flatMap { TreeHelpers.level(of: $0, in: config.tree) } ?? 0 + let treeUpdate = Message.make( + type: .treeUpdate, + senderID: localDeviceID, + senderRole: senderRole(for: localDeviceID, in: config), + parentID: parentID, + treeLevel: treeLevel, + ttl: defaultTTL, + encrypted: false, + latitude: nil, + longitude: nil, + accuracy: nil, + tree: config.tree, + networkVersion: config.version + ) + meshService.publish(treeUpdate) + } + + private func publishPromote(nodeID: String, senderRole: String, in config: NetworkConfig) { + let parentID = TreeHelpers.parent(of: nodeID, in: config.tree)?.id + let treeLevel = TreeHelpers.level(of: nodeID, in: config.tree) ?? 0 + let promoteMessage = Message.make( + type: .promote, + senderID: localDeviceID, + senderRole: senderRole, + parentID: parentID, + treeLevel: treeLevel, + ttl: defaultTTL, + encrypted: false, + latitude: nil, + longitude: nil, + accuracy: nil, + targetNodeID: nodeID, + networkVersion: config.version + ) + meshService.publish(promoteMessage) + } + + private func senderRole(for senderID: String, in config: NetworkConfig) -> String { + senderID == config.createdBy ? "organiser" : "participant" + } + + private func applyLocalConfigSnapshot(_ config: NetworkConfig?) { + networkConfig = config + guard let config else { + activeClaimNodeID = nil + return + } + activeClaimNodeID = firstClaimedNodeID(by: localDeviceID, in: config.tree) + if activeClaimNodeID != nil { + clearRoleReselectionState() + } + } + + private func applyUpdatedConfig(_ config: NetworkConfig) { + treeSyncService.setLocalConfig(config) + applyLocalConfigSnapshot(config) + } + + private func clearRoleReselectionState() { + requiresRoleReselection = false + roleReselectionNotification = nil + } + + @discardableResult + private func insertChild(parentID: String, label: String, in tree: inout TreeNode) -> TreeNode? { + if tree.id == parentID { + let createdNode = TreeNode( + id: UUID().uuidString, + label: label, + claimedBy: nil, + children: [] + ) + tree.children.append(createdNode) + return createdNode + } + + for index in tree.children.indices { + if let createdNode = insertChild(parentID: parentID, label: label, in: &tree.children[index]) { + return createdNode + } + } + + return nil + } + + @discardableResult + private func removeTreeNode(nodeID: String, from tree: inout TreeNode) -> TreeNode? { + if let index = tree.children.firstIndex(where: { $0.id == nodeID }) { + return tree.children.remove(at: index) + } + + for index in tree.children.indices { + if let removed = removeTreeNode(nodeID: nodeID, from: &tree.children[index]) { + return removed + } + } + + return nil + } + + @discardableResult + private func renameTreeNode(nodeID: String, newLabel: String, in tree: inout TreeNode) -> Bool { + if tree.id == nodeID { + tree.label = newLabel + return true + } + + for index in tree.children.indices { + if renameTreeNode(nodeID: nodeID, newLabel: newLabel, in: &tree.children[index]) { + return true + } + } + + return false + } + + @discardableResult + private func appendChild(_ child: TreeNode, toParentID parentID: String, in tree: inout TreeNode) -> Bool { + if tree.id == parentID { + tree.children.append(child) + return true + } + + for index in tree.children.indices { + if appendChild(child, toParentID: parentID, in: &tree.children[index]) { + return true + } + } + + return false + } + + private func treeContainsNode(withID nodeID: String, in tree: TreeNode) -> Bool { + if tree.id == nodeID { + return true + } + + for child in tree.children { + if treeContainsNode(withID: nodeID, in: child) { + return true + } + } + + return false + } + + @discardableResult + private func reorderSiblingNode(nodeID: String, beforeSiblingID: String, in tree: inout TreeNode) -> Bool { + let childIDs = tree.children.map(\.id) + if let fromIndex = childIDs.firstIndex(of: nodeID), + let toIndex = childIDs.firstIndex(of: beforeSiblingID) { + guard fromIndex != toIndex else { + return false + } + + let movingNode = tree.children.remove(at: fromIndex) + let destinationIndex = fromIndex < toIndex ? max(0, toIndex - 1) : toIndex + tree.children.insert(movingNode, at: destinationIndex) + return true + } + + for index in tree.children.indices { + if reorderSiblingNode(nodeID: nodeID, beforeSiblingID: beforeSiblingID, in: &tree.children[index]) { + return true + } + } + + return false + } + + private func findNode(withID nodeID: String, in tree: TreeNode) -> TreeNode? { + if tree.id == nodeID { + return tree + } + + for child in tree.children { + if let found = findNode(withID: nodeID, in: child) { + return found + } + } + + return nil + } + + @discardableResult + private func updateClaim(nodeID: String, claimedBy: String?, in tree: inout TreeNode) -> Bool { + if tree.id == nodeID { + tree.claimedBy = claimedBy + return true + } + + for index in tree.children.indices { + if updateClaim(nodeID: nodeID, claimedBy: claimedBy, in: &tree.children[index]) { + return true + } + } + + return false + } + + private func firstClaimedNodeID(by ownerID: String, in tree: TreeNode) -> String? { + if tree.claimedBy == ownerID { + return tree.id + } + + for child in tree.children { + if let claimedNodeID = firstClaimedNodeID(by: ownerID, in: child) { + return claimedNodeID + } + } + + return nil + } + + private func claimedNodeIDs(by ownerID: String, in tree: TreeNode) -> [String] { + var claimedNodeIDs: [String] = [] + collectClaimedNodeIDs(by: ownerID, in: tree, into: &claimedNodeIDs) + return claimedNodeIDs + } + + private func collectClaimedNodeIDs(by ownerID: String, in tree: TreeNode, into collection: inout [String]) { + if tree.claimedBy == ownerID { + collection.append(tree.id) + } + + for child in tree.children { + collectClaimedNodeIDs(by: ownerID, in: child, into: &collection) + } + } +} + +// MARK: - Log helpers + +private extension CBManagerState { + var logDescription: String { + switch self { + case .unknown: return "unknown" + case .resetting: return "resetting" + case .unsupported: return "unsupported" + case .unauthorized: return "unauthorized" + case .poweredOff: return "poweredOff" + case .poweredOn: return "poweredOn" + @unknown default: return "unknown(\(rawValue))" + } + } +} diff --git a/TacNet/Services/Cactus.swift b/TacNet/Services/Cactus.swift new file mode 100644 index 00000000..386cd398 --- /dev/null +++ b/TacNet/Services/Cactus.swift @@ -0,0 +1,1520 @@ +import Foundation +import ZIPFoundation +import cactus + +public typealias CactusModelT = UnsafeMutableRawPointer +public typealias CactusIndexT = UnsafeMutableRawPointer +public typealias CactusStreamTranscribeT = UnsafeMutableRawPointer + +private let _frameworkInitialized: Void = { + cactus_set_telemetry_environment("swift", nil, nil) + if let bundleId = Bundle.main.bundleIdentifier { + bundleId.withCString { cactus_set_app_id($0) } + } +}() + +private func _err(_ msg: String) -> NSError { + let e = cactusGetLastError() + let desc = e.isEmpty ? msg : e + return NSError(domain: "cactus", code: -1, userInfo: [NSLocalizedDescriptionKey: desc]) +} + +private class TokenCallbackContext { + let callback: (String, UInt32) -> Void + init(callback: @escaping (String, UInt32) -> Void) { + self.callback = callback + } +} + +private func tokenCallbackBridge(token: UnsafePointer?, tokenId: UInt32, userData: UnsafeMutableRawPointer?) { + guard let token = token, let userData = userData else { return } + let context = Unmanaged.fromOpaque(userData).takeUnretainedValue() + context.callback(String(cString: token), tokenId) +} + +private let _defaultBufferSize = 65536 + +// MARK: - Telemetry + +public func cactusGetLastError() -> String { + return String(cString: cactus_get_last_error()) +} + +public func cactusSetTelemetryEnvironment(_ path: String) { + cactus_set_telemetry_environment(nil, path, nil) +} + +public func cactusSetAppId(_ appId: String) { + appId.withCString { cactus_set_app_id($0) } +} + +public func cactusTelemetryFlush() { + cactus_telemetry_flush() +} + +public func cactusTelemetryShutdown() { + cactus_telemetry_shutdown() +} + +// MARK: - Model lifecycle + +public func cactusInit(_ modelPath: String, _ corpusDir: String?, _ cacheIndex: Bool) throws -> CactusModelT { + _ = _frameworkInitialized + guard let h = cactus_init(modelPath, corpusDir, cacheIndex) else { + throw _err("Failed to initialize model") + } + return h +} + +public func cactusDestroy(_ model: CactusModelT) { + cactus_destroy(model) +} + +public func cactusReset(_ model: CactusModelT) { + cactus_reset(model) +} + +public func cactusStop(_ model: CactusModelT) { + cactus_stop(model) +} + +// MARK: - Inference + +public func cactusComplete(_ model: CactusModelT, _ messagesJson: String, _ optionsJson: String?, _ toolsJson: String?, _ onToken: ((String, UInt32) -> Void)?, _ pcmData: Data? = nil) throws -> String { + var buffer = [CChar](repeating: 0, count: _defaultBufferSize) + + let callbackContext = onToken.map { TokenCallbackContext(callback: $0) } + let contextPtr = callbackContext.map { Unmanaged.passUnretained($0).toOpaque() } + + let result: Int32 + if let pcmData = pcmData { + result = pcmData.withUnsafeBytes { pcmPtr in + buffer.withUnsafeMutableBufferPointer { bufferPtr in + cactus_complete( + model, + messagesJson, + bufferPtr.baseAddress, + bufferPtr.count, + optionsJson, + toolsJson, + onToken != nil ? tokenCallbackBridge : nil, + contextPtr, + pcmPtr.baseAddress?.assumingMemoryBound(to: UInt8.self), pcmData.count + ) + } + } + } else { + result = buffer.withUnsafeMutableBufferPointer { bufferPtr in + cactus_complete( + model, + messagesJson, + bufferPtr.baseAddress, + bufferPtr.count, + optionsJson, + toolsJson, + onToken != nil ? tokenCallbackBridge : nil, + contextPtr, + nil, 0 + ) + } + } + + if result < 0 { throw _err("Completion failed") } + return String(cString: buffer) +} + +public func cactusPrefill(_ model: CactusModelT, _ messagesJson: String, _ optionsJson: String?, _ toolsJson: String?, _ pcmData: Data? = nil) throws -> String { + var buffer = [CChar](repeating: 0, count: _defaultBufferSize) + + let result: Int32 + if let pcmData = pcmData { + result = pcmData.withUnsafeBytes { pcmPtr in + buffer.withUnsafeMutableBufferPointer { bufferPtr in + cactus_prefill( + model, + messagesJson, + bufferPtr.baseAddress, + bufferPtr.count, + optionsJson, + toolsJson, + pcmPtr.baseAddress?.assumingMemoryBound(to: UInt8.self), pcmData.count + ) + } + } + } else { + result = buffer.withUnsafeMutableBufferPointer { bufferPtr in + cactus_prefill( + model, + messagesJson, + bufferPtr.baseAddress, + bufferPtr.count, + optionsJson, + toolsJson, + nil, 0 + ) + } + } + + if result < 0 { throw _err("Prefill failed") } + return String(cString: buffer) +} + +public func cactusTokenize(_ model: CactusModelT, _ text: String) throws -> [UInt32] { + var tokenBuffer = [UInt32](repeating: 0, count: 8192) + var tokenLen: Int = 0 + + let result = tokenBuffer.withUnsafeMutableBufferPointer { bufferPtr in + cactus_tokenize(model, text, bufferPtr.baseAddress, bufferPtr.count, &tokenLen) + } + + if result < 0 { throw _err("Tokenization failed") } + return Array(tokenBuffer.prefix(tokenLen)) +} + +public func cactusScoreWindow(_ model: CactusModelT, _ tokens: [UInt32], _ start: Int, _ end: Int, _ context: Int) throws -> String { + var buffer = [CChar](repeating: 0, count: _defaultBufferSize) + + let result = tokens.withUnsafeBufferPointer { tokenPtr in + buffer.withUnsafeMutableBufferPointer { bufferPtr in + cactus_score_window( + model, + tokenPtr.baseAddress, tokenPtr.count, + start, end, context, + bufferPtr.baseAddress, bufferPtr.count + ) + } + } + + if result < 0 { throw _err("Score window failed") } + return String(cString: buffer) +} + +public func cactusDetectLanguage(_ model: CactusModelT, _ audioPath: String?, _ optionsJson: String?, _ pcmData: Data?) throws -> String { + var buffer = [CChar](repeating: 0, count: _defaultBufferSize) + + let result: Int32 + if let pcmData = pcmData { + result = pcmData.withUnsafeBytes { pcmPtr in + buffer.withUnsafeMutableBufferPointer { bufferPtr in + cactus_detect_language( + model, audioPath, + bufferPtr.baseAddress, bufferPtr.count, + optionsJson, + pcmPtr.baseAddress?.assumingMemoryBound(to: UInt8.self), pcmData.count + ) + } + } + } else { + result = buffer.withUnsafeMutableBufferPointer { bufferPtr in + cactus_detect_language(model, audioPath, bufferPtr.baseAddress, bufferPtr.count, optionsJson, nil, 0) + } + } + + if result < 0 { throw _err("Detect language failed") } + return String(cString: buffer) +} + +public func cactusTranscribe(_ model: CactusModelT, _ audioPath: String?, _ prompt: String?, _ optionsJson: String?, _ onToken: ((String, UInt32) -> Void)?, _ pcmData: Data?) throws -> String { + var buffer = [CChar](repeating: 0, count: _defaultBufferSize) + let callbackContext = onToken.map { TokenCallbackContext(callback: $0) } + let contextPtr = callbackContext.map { Unmanaged.passUnretained($0).toOpaque() } + + let result: Int32 + if let pcmData = pcmData { + result = pcmData.withUnsafeBytes { pcmPtr in + buffer.withUnsafeMutableBufferPointer { bufferPtr in + cactus_transcribe( + model, audioPath, prompt, + bufferPtr.baseAddress, bufferPtr.count, + optionsJson, + onToken != nil ? tokenCallbackBridge : nil, contextPtr, + pcmPtr.baseAddress?.assumingMemoryBound(to: UInt8.self), pcmData.count + ) + } + } + } else { + result = buffer.withUnsafeMutableBufferPointer { bufferPtr in + cactus_transcribe( + model, audioPath, prompt, + bufferPtr.baseAddress, bufferPtr.count, + optionsJson, + onToken != nil ? tokenCallbackBridge : nil, contextPtr, + nil, 0 + ) + } + } + + if result < 0 { throw _err("Transcription failed") } + return String(cString: buffer) +} + +public func cactusStreamTranscribeStart(_ model: CactusModelT, _ optionsJson: String?) throws -> CactusStreamTranscribeT { + guard let h = cactus_stream_transcribe_start(model, optionsJson) else { + throw _err("Failed to create stream transcriber") + } + return h +} + +public func cactusStreamTranscribeProcess(_ stream: CactusStreamTranscribeT, _ pcmData: Data) throws -> String { + var buffer = [CChar](repeating: 0, count: _defaultBufferSize) + + let result = pcmData.withUnsafeBytes { pcmPtr in + buffer.withUnsafeMutableBufferPointer { bufferPtr in + cactus_stream_transcribe_process( + stream, + pcmPtr.baseAddress?.assumingMemoryBound(to: UInt8.self), + pcmData.count, + bufferPtr.baseAddress, + bufferPtr.count + ) + } + } + + if result < 0 { throw _err("Stream process failed") } + return String(cString: buffer) +} + +public func cactusStreamTranscribeStop(_ stream: CactusStreamTranscribeT) throws -> String { + var buffer = [CChar](repeating: 0, count: _defaultBufferSize) + + let result = buffer.withUnsafeMutableBufferPointer { bufferPtr in + cactus_stream_transcribe_stop(stream, bufferPtr.baseAddress, bufferPtr.count) + } + + if result < 0 { throw _err("Stream stop failed") } + return String(cString: buffer) +} + +public func cactusEmbed(_ model: CactusModelT, _ text: String, _ normalize: Bool) throws -> [Float] { + var embeddingBuffer = [Float](repeating: 0, count: 4096) + var embeddingDim: Int = 0 + + let result = embeddingBuffer.withUnsafeMutableBufferPointer { bufferPtr in + cactus_embed(model, text, bufferPtr.baseAddress, bufferPtr.count, &embeddingDim, normalize) + } + + if result < 0 { throw _err("Embedding failed") } + return Array(embeddingBuffer.prefix(embeddingDim)) +} + +public func cactusImageEmbed(_ model: CactusModelT, _ imagePath: String) throws -> [Float] { + var embeddingBuffer = [Float](repeating: 0, count: 4096) + var embeddingDim: Int = 0 + + let result = embeddingBuffer.withUnsafeMutableBufferPointer { bufferPtr in + cactus_image_embed(model, imagePath, bufferPtr.baseAddress, bufferPtr.count, &embeddingDim) + } + + if result < 0 { throw _err("Image embedding failed") } + return Array(embeddingBuffer.prefix(embeddingDim)) +} + +public func cactusAudioEmbed(_ model: CactusModelT, _ audioPath: String) throws -> [Float] { + var embeddingBuffer = [Float](repeating: 0, count: 4096) + var embeddingDim: Int = 0 + + let result = embeddingBuffer.withUnsafeMutableBufferPointer { bufferPtr in + cactus_audio_embed(model, audioPath, bufferPtr.baseAddress, bufferPtr.count, &embeddingDim) + } + + if result < 0 { throw _err("Audio embedding failed") } + return Array(embeddingBuffer.prefix(embeddingDim)) +} + +public func cactusVad(_ model: CactusModelT, _ audioPath: String?, _ optionsJson: String?, _ pcmData: Data?) throws -> String { + var buffer = [CChar](repeating: 0, count: _defaultBufferSize) + + let result: Int32 + if let pcmData = pcmData { + result = pcmData.withUnsafeBytes { pcmPtr in + buffer.withUnsafeMutableBufferPointer { bufferPtr in + cactus_vad( + model, audioPath, + bufferPtr.baseAddress, bufferPtr.count, + optionsJson, + pcmPtr.baseAddress?.assumingMemoryBound(to: UInt8.self), pcmData.count + ) + } + } + } else { + result = buffer.withUnsafeMutableBufferPointer { bufferPtr in + cactus_vad(model, audioPath, bufferPtr.baseAddress, bufferPtr.count, optionsJson, nil, 0) + } + } + + if result < 0 { throw _err("VAD failed") } + return String(cString: buffer) +} + +public func cactusDiarize(_ model: CactusModelT, _ audioPath: String?, _ optionsJson: String?, _ pcmData: Data?) throws -> String { + var buffer = [CChar](repeating: 0, count: 1 << 20) + + let result: Int32 + if let pcmData = pcmData { + result = pcmData.withUnsafeBytes { pcmPtr in + buffer.withUnsafeMutableBufferPointer { bufferPtr in + cactus_diarize( + model, audioPath, + bufferPtr.baseAddress, bufferPtr.count, optionsJson, + pcmPtr.baseAddress?.assumingMemoryBound(to: UInt8.self), pcmData.count + ) + } + } + } else { + result = buffer.withUnsafeMutableBufferPointer { bufferPtr in + cactus_diarize(model, audioPath, bufferPtr.baseAddress, bufferPtr.count, optionsJson, nil, 0) + } + } + + if result < 0 { throw _err("Diarize failed") } + return String(cString: buffer) +} + +public func cactusEmbedSpeaker(_ model: CactusModelT, _ audioPath: String?, _ optionsJson: String?, _ pcmData: Data?, _ maskWeights: [Float]? = nil) throws -> String { + var buffer = [CChar](repeating: 0, count: _defaultBufferSize) + + let result: Int32 + if let pcmData = pcmData { + result = pcmData.withUnsafeBytes { pcmPtr in + buffer.withUnsafeMutableBufferPointer { bufferPtr in + if let maskWeights = maskWeights { + return maskWeights.withUnsafeBufferPointer { maskPtr in + cactus_embed_speaker( + model, audioPath, + bufferPtr.baseAddress, bufferPtr.count, optionsJson, + pcmPtr.baseAddress?.assumingMemoryBound(to: UInt8.self), pcmData.count, + maskPtr.baseAddress, maskWeights.count + ) + } + } else { + return cactus_embed_speaker( + model, audioPath, + bufferPtr.baseAddress, bufferPtr.count, optionsJson, + pcmPtr.baseAddress?.assumingMemoryBound(to: UInt8.self), pcmData.count, + nil, 0 + ) + } + } + } + } else { + result = buffer.withUnsafeMutableBufferPointer { bufferPtr in + if let maskWeights = maskWeights { + return maskWeights.withUnsafeBufferPointer { maskPtr in + cactus_embed_speaker(model, audioPath, bufferPtr.baseAddress, bufferPtr.count, optionsJson, nil, 0, maskPtr.baseAddress, maskWeights.count) + } + } else { + return cactus_embed_speaker(model, audioPath, bufferPtr.baseAddress, bufferPtr.count, optionsJson, nil, 0, nil, 0) + } + } + } + + if result < 0 { throw _err("EmbedSpeaker failed") } + return String(cString: buffer) +} + +public func cactusRagQuery(_ model: CactusModelT, _ query: String, _ topK: Int) throws -> String { + var buffer = [CChar](repeating: 0, count: _defaultBufferSize) + + let result = buffer.withUnsafeMutableBufferPointer { bufferPtr in + cactus_rag_query(model, query, bufferPtr.baseAddress, bufferPtr.count, topK) + } + + if result < 0 { throw _err("RAG query failed") } + return String(cString: buffer) +} + +// MARK: - Index + +public func cactusIndexInit(_ indexDir: String, _ embeddingDim: Int) throws -> CactusIndexT { + guard let h = cactus_index_init(indexDir, embeddingDim) else { + throw _err("Failed to initialize index") + } + return h +} + +public func cactusIndexDestroy(_ index: CactusIndexT) { + cactus_index_destroy(index) +} + +public func cactusIndexAdd(_ index: CactusIndexT, _ ids: [Int32], _ documents: [String], _ embeddings: [[Float]], _ metadatas: [String]?) throws { + let count = ids.count + let embeddingDim = embeddings[0].count + + var idArray = ids + var docPtrs = documents.map { strdup($0) } + let metaPtrs: [UnsafeMutablePointer?]? = metadatas?.map { strdup($0) } + var embPtrs = embeddings.map { emb -> UnsafePointer? in + let ptr = UnsafeMutablePointer.allocate(capacity: emb.count) + ptr.initialize(from: emb, count: emb.count) + return UnsafePointer(ptr) + } + + let result = idArray.withUnsafeMutableBufferPointer { idPtr in + docPtrs.withUnsafeMutableBufferPointer { docPtr in + embPtrs.withUnsafeMutableBufferPointer { embPtr in + if let metaPtrs = metaPtrs { + var metaPtrsCopy = metaPtrs + return metaPtrsCopy.withUnsafeMutableBufferPointer { metaPtr in + cactus_index_add( + index, + idPtr.baseAddress, + unsafeBitCast(docPtr.baseAddress, to: UnsafeMutablePointer?>?.self), + unsafeBitCast(metaPtr.baseAddress, to: UnsafeMutablePointer?>?.self), + embPtr.baseAddress, + count, embeddingDim + ) + } + } else { + return cactus_index_add( + index, + idPtr.baseAddress, + unsafeBitCast(docPtr.baseAddress, to: UnsafeMutablePointer?>?.self), + nil, + embPtr.baseAddress, + count, embeddingDim + ) + } + } + } + } + + docPtrs.forEach { free($0) } + metaPtrs?.forEach { free($0) } + embPtrs.forEach { if let p = $0 { UnsafeMutablePointer(mutating: p).deallocate() } } + + if result < 0 { throw _err("Failed to add documents to index") } +} + +public func cactusIndexDelete(_ index: CactusIndexT, _ ids: [Int32]) throws { + var idArray = ids + let result = idArray.withUnsafeMutableBufferPointer { idPtr in + cactus_index_delete(index, idPtr.baseAddress, ids.count) + } + + if result < 0 { throw _err("Failed to delete documents from index") } +} + +public func cactusIndexGet(_ index: CactusIndexT, _ ids: [Int32]) throws -> String { + let count = ids.count + var idArray = ids + + let docBufSize = 4096 + let embBufSize = 4096 + + let docRaw = (0.. UnsafeMutablePointer in + let p = UnsafeMutablePointer.allocate(capacity: docBufSize) + p.initialize(repeating: 0, count: docBufSize) + return p + } + let metaRaw = (0.. UnsafeMutablePointer in + let p = UnsafeMutablePointer.allocate(capacity: docBufSize) + p.initialize(repeating: 0, count: docBufSize) + return p + } + let embRaw = (0.. UnsafeMutablePointer in + let p = UnsafeMutablePointer.allocate(capacity: embBufSize) + p.initialize(repeating: 0, count: embBufSize) + return p + } + defer { + docRaw.forEach { $0.deallocate() } + metaRaw.forEach { $0.deallocate() } + embRaw.forEach { $0.deallocate() } + } + + var docBuffers: [UnsafeMutablePointer?] = docRaw.map { Optional($0) } + var docBufferSizes = [Int](repeating: docBufSize, count: count) + var metaBuffers: [UnsafeMutablePointer?] = metaRaw.map { Optional($0) } + var metaBufferSizes = [Int](repeating: docBufSize, count: count) + var embBuffers: [UnsafeMutablePointer?] = embRaw.map { Optional($0) } + var embBufferSizes = [Int](repeating: embBufSize, count: count) + + let result = idArray.withUnsafeMutableBufferPointer { idPtr in + docBuffers.withUnsafeMutableBufferPointer { docPtr in + docBufferSizes.withUnsafeMutableBufferPointer { docSzPtr in + metaBuffers.withUnsafeMutableBufferPointer { metaPtr in + metaBufferSizes.withUnsafeMutableBufferPointer { metaSzPtr in + embBuffers.withUnsafeMutableBufferPointer { embPtr in + embBufferSizes.withUnsafeMutableBufferPointer { embSzPtr in + cactus_index_get( + index, + idPtr.baseAddress, count, + docPtr.baseAddress, + docSzPtr.baseAddress, + metaPtr.baseAddress, + metaSzPtr.baseAddress, + embPtr.baseAddress, + embSzPtr.baseAddress + ) + } + } + } + } + } + } + } + + if result < 0 { throw _err("Failed to get from index") } + + var sb = "{\"results\":[" + for i in 0.. 0 { sb += "," } + let doc = String(cString: docRaw[i]) + let metaStr = String(cString: metaRaw[i]) + sb += "{\"document\":\"\(doc)\"" + if !metaStr.isEmpty { + sb += ",\"metadata\":\"\(metaStr)\"" + } else { + sb += ",\"metadata\":null" + } + sb += ",\"embedding\":[" + let embDim = embBufferSizes[i] + for j in 0.. 0 { sb += "," } + sb += "\(embRaw[i][j])" + } + sb += "]}" + } + sb += "]}" + return sb +} + +public func cactusIndexQuery(_ index: CactusIndexT, _ embedding: [Float], _ optionsJson: String?) throws -> String { + let resultCapacity = 1000 + var embeddingCopy = embedding + var idBuffer = [Int32](repeating: 0, count: resultCapacity) + var scoreBuffer = [Float](repeating: 0, count: resultCapacity) + var idBufferSize = resultCapacity + var scoreBufferSize = resultCapacity + + let result: Int32 + if let optStr = optionsJson { + result = embeddingCopy.withUnsafeMutableBufferPointer { embPtr in + idBuffer.withUnsafeMutableBufferPointer { idPtr in + scoreBuffer.withUnsafeMutableBufferPointer { scorePtr in + var embPtrPtr: UnsafePointer? = embPtr.baseAddress.map { UnsafePointer($0) } + var idPtrPtr: UnsafeMutablePointer? = idPtr.baseAddress + var scorePtrPtr: UnsafeMutablePointer? = scorePtr.baseAddress + let optStrCopy = optStr + return withUnsafeMutablePointer(to: &embPtrPtr) { embPtrPtrPtr in + withUnsafeMutablePointer(to: &idPtrPtr) { idPtrPtrPtr in + withUnsafeMutablePointer(to: &scorePtrPtr) { scorePtrPtrPtr in + withUnsafeMutablePointer(to: &idBufferSize) { idSizePtr in + withUnsafeMutablePointer(to: &scoreBufferSize) { scoreSizePtr in + optStrCopy.withCString { optCStr in + cactus_index_query( + index, + embPtrPtrPtr, 1, embedding.count, optCStr, + idPtrPtrPtr, idSizePtr, + scorePtrPtrPtr, scoreSizePtr + ) + } + } + } + } + } + } + } + } + } + } else { + result = embeddingCopy.withUnsafeMutableBufferPointer { embPtr in + idBuffer.withUnsafeMutableBufferPointer { idPtr in + scoreBuffer.withUnsafeMutableBufferPointer { scorePtr in + var embPtrPtr: UnsafePointer? = embPtr.baseAddress.map { UnsafePointer($0) } + var idPtrPtr: UnsafeMutablePointer? = idPtr.baseAddress + var scorePtrPtr: UnsafeMutablePointer? = scorePtr.baseAddress + return withUnsafeMutablePointer(to: &embPtrPtr) { embPtrPtrPtr in + withUnsafeMutablePointer(to: &idPtrPtr) { idPtrPtrPtr in + withUnsafeMutablePointer(to: &scorePtrPtr) { scorePtrPtrPtr in + withUnsafeMutablePointer(to: &idBufferSize) { idSizePtr in + withUnsafeMutablePointer(to: &scoreBufferSize) { scoreSizePtr in + cactus_index_query( + index, + embPtrPtrPtr, 1, embedding.count, nil, + idPtrPtrPtr, idSizePtr, + scorePtrPtrPtr, scoreSizePtr + ) + } + } + } + } + } + } + } + } + } + + if result < 0 { throw _err("Index query failed") } + + var sb = "{\"results\":[" + for i in 0.. 0 { sb += "," } + sb += "{\"id\":\(idBuffer[i]),\"score\":\(scoreBuffer[i])}" + } + sb += "]}" + return sb +} + +public func cactusIndexCompact(_ index: CactusIndexT) throws { + let result = cactus_index_compact(index) + if result < 0 { throw _err("Failed to compact index") } +} + +// MARK: - Logging + +public func cactusLogSetLevel(_ level: Int32) { + cactus_log_set_level(level) +} + +private var _logCallbackContext: ((Int32, String, String) -> Void)? + +private func logCallbackBridge(level: Int32, component: UnsafePointer?, message: UnsafePointer?, userData: UnsafeMutableRawPointer?) { + guard let component = component, let message = message else { return } + _logCallbackContext?(level, String(cString: component), String(cString: message)) +} + +public func cactusLogSetCallback(_ callback: ((Int32, String, String) -> Void)?) { + _logCallbackContext = callback + if callback != nil { + cactus_log_set_callback(logCallbackBridge, nil) + } else { + cactus_log_set_callback(nil, nil) + } +} + +// MARK: - Model download + initialization + +public struct ModelDownloadConfiguration: Sendable { + public var modelURL: URL + public var expectedModelSizeBytes: Int64 + public var modelDirectoryName: String + public var modelFileName: String + /// In production this MUST stay `true` so that a real download that returns + /// an HTTP error body (e.g. a few bytes of "Access denied" text) is rejected + /// with `ModelDownloadServiceError.invalidArchive` instead of being moved + /// to the sentinel path and later crashing Cactus at load time. Unit tests + /// that intentionally script small non-zip mock payloads can flip this to + /// `false` to keep their existing fixture-based flow working. + public var requiresZipArchive: Bool + + public init( + modelURL: URL, + expectedModelSizeBytes: Int64, + modelDirectoryName: String, + modelFileName: String, + requiresZipArchive: Bool = true + ) { + self.modelURL = modelURL + self.expectedModelSizeBytes = expectedModelSizeBytes + self.modelDirectoryName = modelDirectoryName + self.modelFileName = modelFileName + self.requiresZipArchive = requiresZipArchive + } + + public static let live = ModelDownloadConfiguration( + modelURL: URL(string: "https://huggingface.co/Cactus-Compute/gemma-4-E4B-it/resolve/main/weights/gemma-4-e4b-it-int4-apple.zip")!, + expectedModelSizeBytes: 6_439_205_261, + modelDirectoryName: "gemma-4-e4b-it", + modelFileName: ".complete", + requiresZipArchive: true + ) +} + +public struct ModelDownloadRequest: Sendable { + public let url: URL + public let resumeData: Data? + + public init(url: URL, resumeData: Data?) { + self.url = url + self.resumeData = resumeData + } +} + +public protocol URLSessionDownloading: AnyObject { + func download( + request: ModelDownloadRequest, + progress: @escaping @Sendable (Int64, Int64) -> Void + ) async throws -> URL +} + +public protocol StorageChecking: Sendable { + func availableStorageBytes(for url: URL) throws -> Int64 +} + +public struct VolumeStorageChecker: StorageChecking { + public init() {} + + public func availableStorageBytes(for url: URL) throws -> Int64 { + let values = try url.resourceValues(forKeys: [ + .volumeAvailableCapacityForImportantUsageKey, + .volumeAvailableCapacityKey + ]) + + if let importantCapacity = values.volumeAvailableCapacityForImportantUsage { + return Int64(importantCapacity) + } + + if let generalCapacity = values.volumeAvailableCapacity { + return Int64(generalCapacity) + } + + throw NSError( + domain: "TacNet.ModelDownload", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Unable to read available storage capacity."] + ) + } +} + +public enum URLSessionDownloadClientError: Error { + case interrupted(resumeData: Data?) + case missingTemporaryFile + case transport(NSError) + case httpError(statusCode: Int) +} + +public final class URLSessionDownloadClient: NSObject, URLSessionDownloading { + private struct CallbackBundle { + let progress: @Sendable (Int64, Int64) -> Void + let completion: @Sendable (Result) -> Void + } + + private let lock = NSLock() + private var callbacksByTaskID: [Int: CallbackBundle] = [:] + private var downloadedLocationsByTaskID: [Int: URL] = [:] + + private lazy var session: URLSession = { + let config = URLSessionConfiguration.default + config.timeoutIntervalForRequest = 300 + config.timeoutIntervalForResource = 86400 + config.waitsForConnectivity = true + return URLSession(configuration: config, delegate: self, delegateQueue: nil) + }() + + public func download( + request: ModelDownloadRequest, + progress: @escaping @Sendable (Int64, Int64) -> Void + ) async throws -> URL { + try await withCheckedThrowingContinuation { continuation in + let task: URLSessionDownloadTask + if let resumeData = request.resumeData, !resumeData.isEmpty { + task = session.downloadTask(withResumeData: resumeData) + } else { + var urlRequest = URLRequest(url: request.url) + urlRequest.setValue("1", forHTTPHeaderField: "X-HF-No-Xet") + task = session.downloadTask(with: urlRequest) + } + + let bundle = CallbackBundle( + progress: progress, + completion: { result in + continuation.resume(with: result) + } + ) + + lock.withLock { + callbacksByTaskID[task.taskIdentifier] = bundle + } + + task.resume() + } + } +} + +extension URLSessionDownloadClient: URLSessionDownloadDelegate { + public func urlSession( + _: URLSession, + downloadTask: URLSessionDownloadTask, + didWriteData _: Int64, + totalBytesWritten: Int64, + totalBytesExpectedToWrite: Int64 + ) { + let callback = lock.withLock { callbacksByTaskID[downloadTask.taskIdentifier] } + callback?.progress(totalBytesWritten, totalBytesExpectedToWrite) + } + + public func urlSession( + _: URLSession, + downloadTask: URLSessionDownloadTask, + didFinishDownloadingTo location: URL + ) { + // Apple deletes the temp file when this method returns, + // so move it to a stable location immediately. + let stableURL = FileManager.default.temporaryDirectory + .appendingPathComponent("TacNet_download_\(downloadTask.taskIdentifier).tmp") + do { + if FileManager.default.fileExists(atPath: stableURL.path) { + try FileManager.default.removeItem(at: stableURL) + } + try FileManager.default.moveItem(at: location, to: stableURL) + lock.withLock { + downloadedLocationsByTaskID[downloadTask.taskIdentifier] = stableURL + } + } catch { + lock.withLock { + downloadedLocationsByTaskID[downloadTask.taskIdentifier] = nil + } + } + } + + public func urlSession(_: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + let callbackBundle: CallbackBundle? = lock.withLock { + defer { + callbacksByTaskID.removeValue(forKey: task.taskIdentifier) + } + return callbacksByTaskID[task.taskIdentifier] + } + + guard let callbackBundle else { return } + + if let error { + let nsError = error as NSError + let resumeData = nsError.userInfo[NSURLSessionDownloadTaskResumeData] as? Data + if resumeData != nil { + callbackBundle.completion(.failure(URLSessionDownloadClientError.interrupted(resumeData: resumeData))) + } else { + callbackBundle.completion(.failure(URLSessionDownloadClientError.transport(nsError))) + } + return + } + + let downloadedLocation = lock.withLock { + defer { + downloadedLocationsByTaskID.removeValue(forKey: task.taskIdentifier) + } + return downloadedLocationsByTaskID[task.taskIdentifier] + } + + guard let downloadedLocation else { + callbackBundle.completion(.failure(URLSessionDownloadClientError.missingTemporaryFile)) + return + } + + if let httpResponse = task.response as? HTTPURLResponse, + !(200..<300).contains(httpResponse.statusCode) { + try? FileManager.default.removeItem(at: downloadedLocation) + callbackBundle.completion(.failure(URLSessionDownloadClientError.httpError(statusCode: httpResponse.statusCode))) + return + } + + callbackBundle.completion(.success(downloadedLocation)) + } +} + +public enum ModelDownloadServiceError: Error, Equatable { + case insufficientStorage(requiredBytes: Int64, availableBytes: Int64) + case interrupted(canResume: Bool) + case network(underlyingDescription: String) + /// The server returned a payload that is not a ZIP archive (no `PK\x03\x04` + /// magic bytes) while the service is running in production mode + /// (`ModelDownloadConfiguration.requiresZipArchive == true`). The gate is + /// left closed and no sentinel file is written. + case invalidArchive +} + +public actor ModelDownloadService { + public typealias ProgressHandler = @Sendable (Double) -> Void + + public static let live = ModelDownloadService() + + private let configuration: ModelDownloadConfiguration + private let downloader: URLSessionDownloading + private let storageChecker: StorageChecking + private let fileManager: FileManager + private let userDefaults: UserDefaults + private let applicationSupportDirectory: URL + private let completionKey: String + private let resumeDataKey: String + + public init( + configuration: ModelDownloadConfiguration = .live, + downloader: URLSessionDownloading = URLSessionDownloadClient(), + storageChecker: StorageChecking = VolumeStorageChecker(), + fileManager: FileManager = .default, + userDefaults: UserDefaults = .standard, + applicationSupportDirectory: URL? = nil, + persistenceKeyPrefix: String = "TacNet.ModelDownload" + ) { + self.configuration = configuration + self.downloader = downloader + self.storageChecker = storageChecker + self.fileManager = fileManager + self.userDefaults = userDefaults + self.applicationSupportDirectory = applicationSupportDirectory + ?? fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? fileManager.temporaryDirectory + completionKey = "\(persistenceKeyPrefix).complete" + resumeDataKey = "\(persistenceKeyPrefix).resumeData" + } + + public func canUseTacticalFeatures() -> Bool { + let ready = synchronizeCompletionState() + if ready { + NSLog("[ModelDownload] ✅ Model ready at %@", modelDirectoryURL.path) + } else { + NSLog("[ModelDownload] ⚠️ Model NOT ready — sentinel: %@, dir: %@", + fileManager.fileExists(atPath: modelFileURL.path) ? "present" : "missing", + fileManager.fileExists(atPath: modelDirectoryURL.path) ? "present" : "missing") + } + return ready + } + + public func downloadedModelDirectoryPath() -> String? { + synchronizeCompletionState() ? modelDirectoryURL.path : nil + } + + private static let maxRetryAttempts = 5 + private static let retryBaseDelay: UInt64 = 2_000_000_000 // 2 seconds in nanoseconds + + @discardableResult + public func ensureModelAvailable(progressHandler: ProgressHandler? = nil) async throws -> URL { + if synchronizeCompletionState() { + NSLog("[ModelDownload] Model already present at %@, skipping download", modelDirectoryURL.path) + progressHandler?(1.0) + return modelDirectoryURL + } + + // Diagnostic: log what's currently in applicationSupportDirectory so we + // can confirm whether orphaned model files are present before migrating. + let appSupportContents = (try? fileManager.contentsOfDirectory(atPath: applicationSupportDirectory.path)) ?? [] + let weightsInRoot = appSupportContents.filter { ($0 as NSString).pathExtension == "weights" }.count + NSLog("[ModelDownload] Diagnostic — app support root contains %d total items, %d .weights files", appSupportContents.count, weightsInRoot) + + // Recovery: a previous extraction may have landed files directly in + // applicationSupportDirectory instead of modelDirectoryURL (wrong destination + // bug). If we find >500 .weights files there, migrate them in-place rather + // than re-downloading 6.4 GB. + if recoverMisplacedExtraction() { + NSLog("[ModelDownload] Recovery migration succeeded — skipping download") + progressHandler?(1.0) + return modelDirectoryURL + } + + let availableStorage = try storageChecker.availableStorageBytes(for: applicationSupportDirectory) + NSLog("[ModelDownload] Storage check — available: %lld bytes, required: %lld bytes", availableStorage, configuration.expectedModelSizeBytes) + guard availableStorage >= configuration.expectedModelSizeBytes else { + throw ModelDownloadServiceError.insufficientStorage( + requiredBytes: configuration.expectedModelSizeBytes, + availableBytes: availableStorage + ) + } + + try fileManager.createDirectory(at: modelDirectoryURL, withIntermediateDirectories: true) + + let progressReporter = ProgressReporter(progressHandler: progressHandler) + progressReporter.report(0) + + var lastNonRecoverableError: ModelDownloadServiceError? + + retryLoop: for attempt in 0.. 0 { + let delay = Self.retryBaseDelay * UInt64(min(attempt, 4)) + NSLog("[ModelDownload] Retry attempt %d, waiting %llu ns", attempt, delay) + try await Task.sleep(nanoseconds: delay) + } + + NSLog("[ModelDownload] Attempt %d — starting download from %@", attempt, configuration.modelURL.absoluteString) + + let request = ModelDownloadRequest( + url: configuration.modelURL, + resumeData: userDefaults.data(forKey: resumeDataKey) + ) + + do { + let temporaryLocation = try await downloader.download( + request: request, + progress: { written, total in + progressReporter.report(bytesWritten: written, totalBytes: total) + } + ) + + NSLog("[ModelDownload] Download finished — temporary file at %@", temporaryLocation.path) + + let fileAttributes = try fileManager.attributesOfItem(atPath: temporaryLocation.path) + let fileSize = (fileAttributes[.size] as? Int64) ?? 0 + let looksLikeZip = Self.fileHasZipMagicBytes(at: temporaryLocation) + NSLog("[ModelDownload] Downloaded file size: %lld bytes (isZip: %@)", fileSize, looksLikeZip ? "YES" : "NO") + + if looksLikeZip { + // Zip payload — validate size, extract into the model directory, + // and write a sentinel so future launches skip the download. + let minimumExpectedSize = configuration.expectedModelSizeBytes / 4 + NSLog("[ModelDownload] Zip size: %lld bytes (minimum expected: %lld bytes)", fileSize, minimumExpectedSize) + guard fileSize >= minimumExpectedSize else { + try? fileManager.removeItem(at: temporaryLocation) + lastNonRecoverableError = .network( + underlyingDescription: "Downloaded file is too small (\(fileSize) bytes). Expected ~\(configuration.expectedModelSizeBytes) bytes. The model URL may be inaccessible or require authentication." + ) + break retryLoop + } + + // Remove any previously partially-extracted model directory so + // the extraction always starts from a clean slate. + if fileManager.fileExists(atPath: modelDirectoryURL.path) { + NSLog("[ModelDownload] Removing previous partial extraction at %@", modelDirectoryURL.path) + try fileManager.removeItem(at: modelDirectoryURL) + } + + NSLog("[ModelDownload] Extracting zip to %@", modelDirectoryURL.path) + // The zip contains 2088 .weights files at its root (no top-level + // subdirectory), so extract directly into modelDirectoryURL so + // all files land in the right place for cactusInit. + try fileManager.unzipItem(at: temporaryLocation, to: modelDirectoryURL) + try? fileManager.removeItem(at: temporaryLocation) + NSLog("[ModelDownload] Extraction complete") + + guard fileManager.fileExists(atPath: modelDirectoryURL.path) else { + lastNonRecoverableError = .network( + underlyingDescription: "Zip extraction completed but the model directory was not found at the expected path. The zip may have a different internal structure." + ) + break retryLoop + } + + // Verify extraction integrity: count files AND check for zero-byte + // weight files that indicate truncated extraction (ZIP64 edge case + // or iOS memory pressure during unzip). + let extractedContents = (try? fileManager.contentsOfDirectory(atPath: modelDirectoryURL.path)) ?? [] + let weightsFiles = extractedContents.filter { ($0 as NSString).pathExtension == "weights" } + NSLog("[ModelDownload] Extracted %d total files (%d .weights) into %@", extractedContents.count, weightsFiles.count, modelDirectoryURL.path) + + // Check for zero-byte or suspiciously small weight files + var corruptFiles: [String] = [] + for weightFile in weightsFiles { + let filePath = modelDirectoryURL.appendingPathComponent(weightFile).path + if let attrs = try? fileManager.attributesOfItem(atPath: filePath), + let size = attrs[.size] as? Int64, size == 0 { + corruptFiles.append(weightFile) + } + } + + if !corruptFiles.isEmpty { + NSLog("[ModelDownload] ❌ Found %d zero-byte weight files — extraction was incomplete: %@", + corruptFiles.count, corruptFiles.prefix(5).joined(separator: ", ")) + try? fileManager.removeItem(at: modelDirectoryURL) + lastNonRecoverableError = .network( + underlyingDescription: "Zip extraction produced \(corruptFiles.count) zero-byte weight files. The archive may be corrupt or extraction was interrupted by memory pressure." + ) + break retryLoop + } + + guard weightsFiles.count >= 500 else { + NSLog("[ModelDownload] ❌ Only %d .weights files extracted (expected 2000+) — extraction incomplete", weightsFiles.count) + try? fileManager.removeItem(at: modelDirectoryURL) + lastNonRecoverableError = .network( + underlyingDescription: "Only \(weightsFiles.count) weight files extracted (expected ~2088). The archive may be corrupt." + ) + break retryLoop + } + + // Write the sentinel so future launches can detect a complete download. + fileManager.createFile(atPath: modelFileURL.path, contents: nil, attributes: nil) + NSLog("[ModelDownload] ✅ Integrity check passed — %d weight files verified, sentinel written", weightsFiles.count) + } else { + // Non-zip payload. In production the real HuggingFace URL + // always serves a zip; anything else (e.g. a small HTTP + // error body such as "Access denied") must be rejected + // BEFORE it is promoted to the sentinel path, otherwise + // Cactus would later crash at load time and the user + // would be left with a falsely-green gate. + if configuration.requiresZipArchive { + NSLog( + "[ModelDownload] ❌ Rejecting non-zip payload (%lld bytes) in production mode — no PK magic bytes", + fileSize + ) + try? fileManager.removeItem(at: temporaryLocation) + // Also scrub any partially-created model directory so + // synchronizeCompletionState() stays false and the gate + // remains closed. + try? fileManager.removeItem(at: modelFileURL) + try? fileManager.removeItem(at: modelDirectoryURL) + userDefaults.set(false, forKey: completionKey) + userDefaults.removeObject(forKey: resumeDataKey) + lastNonRecoverableError = .invalidArchive + break retryLoop + } + + // Test-mode / opt-in path: the downloaded file IS the model + // artifact. Move it into the model directory under the + // configured file name; that file itself acts as the + // sentinel used by synchronizeCompletionState(). This path + // is only reachable when `requiresZipArchive == false`. + if !fileManager.fileExists(atPath: modelDirectoryURL.path) { + try fileManager.createDirectory(at: modelDirectoryURL, withIntermediateDirectories: true) + } + if fileManager.fileExists(atPath: modelFileURL.path) { + try fileManager.removeItem(at: modelFileURL) + } + NSLog("[ModelDownload] Installing non-zip model artifact at %@", modelFileURL.path) + do { + try fileManager.moveItem(at: temporaryLocation, to: modelFileURL) + } catch { + // moveItem can fail across volumes; fall back to copy + remove. + try fileManager.copyItem(at: temporaryLocation, to: modelFileURL) + try? fileManager.removeItem(at: temporaryLocation) + } + NSLog("[ModelDownload] Non-zip install complete at %@", modelFileURL.path) + } + + userDefaults.removeObject(forKey: resumeDataKey) + userDefaults.set(true, forKey: completionKey) + + progressReporter.finish() + return modelDirectoryURL + } catch let error as URLSessionDownloadClientError { + switch error { + case let .interrupted(resumeData): + if let resumeData, !resumeData.isEmpty { + userDefaults.set(resumeData, forKey: resumeDataKey) + NSLog("[ModelDownload] Download interrupted — stored %d bytes of resume data for next attempt", resumeData.count) + } else { + NSLog("[ModelDownload] Download interrupted — no resume data available") + } + // Surface interruption to the caller so it can decide when to + // retry (e.g. user tap). The next ensureModelAvailable() call + // will pick up the stored resume data automatically. + break retryLoop + case let .transport(transportError): + let isTransient = [ + NSURLErrorTimedOut, + NSURLErrorNetworkConnectionLost, + NSURLErrorNotConnectedToInternet, + NSURLErrorCannotConnectToHost + ].contains(transportError.code) + if isTransient { + continue + } + lastNonRecoverableError = .network( + underlyingDescription: "\(transportError.domain)(\(transportError.code)): \(transportError.localizedDescription)" + ) + break retryLoop + case let .httpError(statusCode): + NSLog("[ModelDownload] HTTP error %d from server", statusCode) + lastNonRecoverableError = .network( + underlyingDescription: "Server returned HTTP \(statusCode). Check that the model URL is correct and publicly accessible." + ) + break retryLoop + case .missingTemporaryFile: + NSLog("[ModelDownload] Error: download completed but temp file was missing") + lastNonRecoverableError = .network(underlyingDescription: "Download completed without a temporary file.") + break retryLoop + } + } catch { + NSLog("[ModelDownload] Unexpected error on attempt %d: %@", attempt, error.localizedDescription) + lastNonRecoverableError = .network(underlyingDescription: error.localizedDescription) + break retryLoop + } + } + + if let lastNonRecoverableError { + NSLog("[ModelDownload] Giving up after all attempts — final error: %@", String(describing: lastNonRecoverableError)) + throw lastNonRecoverableError + } + + let hasResumeData = userDefaults.data(forKey: resumeDataKey)?.isEmpty == false + throw ModelDownloadServiceError.interrupted(canResume: hasResumeData) + } + + private var modelDirectoryURL: URL { + applicationSupportDirectory.appendingPathComponent(configuration.modelDirectoryName, isDirectory: true) + } + + private var modelFileURL: URL { + modelDirectoryURL.appendingPathComponent(configuration.modelFileName, isDirectory: false) + } + + /// Peek the first 4 bytes of `url` to see if it starts with the standard + /// ZIP local-file-header magic (`PK\x03\x04`). Non-zip payloads (e.g. raw + /// model binaries used by unit tests or future config variants) are then + /// treated as the final artifact rather than as an archive to extract. + private static func fileHasZipMagicBytes(at url: URL) -> Bool { + guard let handle = try? FileHandle(forReadingFrom: url) else { return false } + defer { try? handle.close() } + let prefix = (try? handle.read(upToCount: 4)) ?? Data() + return prefix == Data([0x50, 0x4B, 0x03, 0x04]) + } + + /// Detects files extracted to the wrong location by a previous buggy run and + /// moves them into `modelDirectoryURL` without re-downloading. Returns `true` + /// if recovery succeeded and the model is ready to use. + @discardableResult + private func recoverMisplacedExtraction() -> Bool { + guard let contents = try? fileManager.contentsOfDirectory(atPath: applicationSupportDirectory.path) else { + return false + } + + let modelExtensions: Set = ["weights", "bias", "json", "txt", "jinja2", "mlpackage", "mlmodelc"] + let orphans = contents.filter { name in + modelExtensions.contains((name as NSString).pathExtension) + } + + // Use .weights count as the fingerprint — the model has 2076 .weights files. + // >500 in app support root is unambiguously a misplaced extraction. + let weightsCount = orphans.filter { ($0 as NSString).pathExtension == "weights" }.count + guard weightsCount > 500 else { return false } + + NSLog("[ModelDownload] Found %d orphaned model files in app support root — migrating (skips 6.4 GB re-download)", orphans.count) + + do { + try fileManager.createDirectory(at: modelDirectoryURL, withIntermediateDirectories: true) + var moved = 0 + for name in orphans { + let src = applicationSupportDirectory.appendingPathComponent(name) + let dst = modelDirectoryURL.appendingPathComponent(name) + guard !fileManager.fileExists(atPath: dst.path) else { continue } + try fileManager.moveItem(at: src, to: dst) + moved += 1 + } + NSLog("[ModelDownload] Migrated %d files to %@", moved, modelDirectoryURL.path) + fileManager.createFile(atPath: modelFileURL.path, contents: nil, attributes: nil) + userDefaults.set(true, forKey: completionKey) + return true + } catch { + NSLog("[ModelDownload] Migration failed: %@", error.localizedDescription) + return false + } + } + + private func synchronizeCompletionState() -> Bool { + // Both the sentinel file AND the model directory must be present. + // If either is missing the extraction was incomplete; clean up on-disk + // artifacts so the next attempt starts clean. We deliberately leave any + // stored resumeData in place — it's consulted by ensureModelAvailable() + // on the next call so an interrupted download can resume from the prior + // byte offset instead of starting over. + guard fileManager.fileExists(atPath: modelFileURL.path), + fileManager.fileExists(atPath: modelDirectoryURL.path) else { + try? fileManager.removeItem(at: modelDirectoryURL) + try? fileManager.removeItem(at: modelFileURL) + userDefaults.set(false, forKey: completionKey) + return false + } + + // Verify the extraction produced a meaningful number of weight files + // AND that none are zero-byte (truncated). A corrupt extraction may + // leave the sentinel in place but with broken weight files, causing + // cactusInit to crash with "Cannot map file". + if let contents = try? fileManager.contentsOfDirectory(atPath: modelDirectoryURL.path) { + let weightsFiles = contents.filter { ($0 as NSString).pathExtension == "weights" } + if weightsFiles.count < 500 { + NSLog("[ModelDownload] ⚠️ Integrity check failed — only %d .weights files found (expected 2000+), forcing re-download", weightsFiles.count) + try? fileManager.removeItem(at: modelDirectoryURL) + try? fileManager.removeItem(at: modelFileURL) + userDefaults.set(false, forKey: completionKey) + return false + } + + // Spot-check a sample of weight files for zero-byte corruption + let sampleFiles = Array(weightsFiles.prefix(20)) + for fileName in sampleFiles { + let filePath = modelDirectoryURL.appendingPathComponent(fileName).path + if let attrs = try? fileManager.attributesOfItem(atPath: filePath), + let size = attrs[.size] as? Int64, size == 0 { + NSLog("[ModelDownload] ⚠️ Zero-byte weight file detected: %@ — forcing re-download", fileName) + try? fileManager.removeItem(at: modelDirectoryURL) + try? fileManager.removeItem(at: modelFileURL) + userDefaults.set(false, forKey: completionKey) + return false + } + } + } + + userDefaults.set(true, forKey: completionKey) + return true + } +} + +public enum CactusModelInitializationError: Error, Equatable { + case downloadIncomplete + case initializationFailed(String) +} + +public actor CactusModelInitializationService { + public typealias InitFunction = (String, String?, Bool) throws -> CactusModelT + public typealias DestroyFunction = (CactusModelT) -> Void + + /// Shared singleton — all components use one model handle, preventing double-load (~5.6 GB RAM). + public static let shared = CactusModelInitializationService() + + private let downloadService: ModelDownloadService + private let initFunction: InitFunction + private let destroyFunction: DestroyFunction + private var loadedModelHandle: CactusModelT? + + public init( + downloadService: ModelDownloadService = .live, + initFunction: @escaping InitFunction = cactusInit, + destroyFunction: @escaping DestroyFunction = cactusDestroy + ) { + self.downloadService = downloadService + self.initFunction = initFunction + self.destroyFunction = destroyFunction + } + + public func initializeModel() async throws -> CactusModelT { + if let loadedModelHandle { + return loadedModelHandle + } + + guard await downloadService.canUseTacticalFeatures(), + let modelDirectoryPath = await downloadService.downloadedModelDirectoryPath() + else { + throw CactusModelInitializationError.downloadIncomplete + } + + return try initialize(using: modelDirectoryPath) + } + + public func initializeModelAfterEnsuringDownload( + progressHandler: ModelDownloadService.ProgressHandler? = nil + ) async throws -> CactusModelT { + if let loadedModelHandle { + return loadedModelHandle + } + + let modelDirectory = try await downloadService.ensureModelAvailable(progressHandler: progressHandler) + return try initialize(using: modelDirectory.path) + } + + public func destroyModelIfLoaded() { + guard let loadedModelHandle else { return } + destroyFunction(loadedModelHandle) + self.loadedModelHandle = nil + } + + private func initialize(using modelPath: String) throws -> CactusModelT { + do { + let handle = try initFunction(modelPath, nil, false) + loadedModelHandle = handle + return handle + } catch { + throw CactusModelInitializationError.initializationFailed(error.localizedDescription) + } + } +} + +// MARK: - Model handle abstraction + +public protocol ModelHandleProviding: Sendable { + func provideModelHandle() async throws -> CactusModelT +} + +extension CactusModelInitializationService: ModelHandleProviding { + public func provideModelHandle() async throws -> CactusModelT { + try await initializeModelAfterEnsuringDownload() + } +} + +// MARK: - Bundled model initialization (for models shipped inside the app bundle) + +public enum BundledModelError: Error, Equatable { + case resourceNotFound(String) + case initializationFailed(String) +} + +public actor BundledModelInitializationService: ModelHandleProviding { + public typealias InitFunction = (String, String?, Bool) throws -> CactusModelT + public typealias DestroyFunction = (CactusModelT) -> Void + + public static let parakeet = BundledModelInitializationService( + bundleResourceDirectory: "ParakeetCTC" + ) + + private let bundleResourceDirectory: String + private let initFunction: InitFunction + private let destroyFunction: DestroyFunction + private var loadedModelHandle: CactusModelT? + + public init( + bundleResourceDirectory: String, + initFunction: @escaping InitFunction = cactusInit, + destroyFunction: @escaping DestroyFunction = cactusDestroy + ) { + self.bundleResourceDirectory = bundleResourceDirectory + self.initFunction = initFunction + self.destroyFunction = destroyFunction + } + + public func provideModelHandle() async throws -> CactusModelT { + if let loadedModelHandle { + return loadedModelHandle + } + + guard let bundlePath = Bundle.main.path(forResource: bundleResourceDirectory, ofType: nil) else { + throw BundledModelError.resourceNotFound( + "Bundled model '\(bundleResourceDirectory)' not found in app bundle" + ) + } + + NSLog("[BundledModel] Loading model from bundle path: %@", bundlePath) + do { + let handle = try initFunction(bundlePath, nil, false) + loadedModelHandle = handle + NSLog("[BundledModel] Model loaded successfully from %@", bundleResourceDirectory) + return handle + } catch { + throw BundledModelError.initializationFailed(error.localizedDescription) + } + } + + public func destroyModelIfLoaded() { + guard let loadedModelHandle else { return } + destroyFunction(loadedModelHandle) + self.loadedModelHandle = nil + } +} + +private final class ProgressReporter: @unchecked Sendable { + private let lock = NSLock() + private let progressHandler: (@Sendable (Double) -> Void)? + private var lastEmittedProgress: Double = 0 + private var nextMilestoneProgress: Double = 0.1 + + init(progressHandler: (@Sendable (Double) -> Void)?) { + self.progressHandler = progressHandler + } + + func report(bytesWritten: Int64, totalBytes: Int64) { + guard totalBytes > 0 else { return } + report(Double(bytesWritten) / Double(totalBytes)) + } + + func report(_ progress: Double) { + lock.withLock { + let clamped = min(max(progress, 0), 1) + emitMilestones(upTo: min(clamped, 0.9)) + + if clamped < 1 { + emitIfNeeded(clamped) + } + } + } + + func finish() { + lock.withLock { + emitMilestones(upTo: 0.9) + emitIfNeeded(1) + } + } + + private func emitMilestones(upTo limit: Double) { + while nextMilestoneProgress <= limit + 0.000_000_1 { + emitIfNeeded(nextMilestoneProgress) + nextMilestoneProgress += 0.1 + } + } + + private func emitIfNeeded(_ progress: Double) { + guard progress > lastEmittedProgress + 0.000_000_1 else { return } + lastEmittedProgress = progress + progressHandler?(progress) + } +} + +private extension NSLock { + func withLock(_ body: () throws -> T) rethrows -> T { + lock() + defer { unlock() } + return try body() + } +} diff --git a/TacNet/Services/TextToSpeechService.swift b/TacNet/Services/TextToSpeechService.swift new file mode 100644 index 00000000..9bc97618 --- /dev/null +++ b/TacNet/Services/TextToSpeechService.swift @@ -0,0 +1,120 @@ +import AVFoundation + +// MARK: - Protocol + +protocol TextToSpeechService: AnyObject, Sendable { + func speak(_ text: String, senderRole: String?) async + func stopSpeaking() async + func setEnabled(_ enabled: Bool) async + var isEnabled: Bool { get async } +} + +// MARK: - AVSpeechSynthesizer implementation + +actor AVSpeechTextToSpeechService: TextToSpeechService { + private var enabled: Bool + private var pendingUtterances: [(text: String, senderRole: String?)] = [] + private var isSpeaking = false + + private static let enabledKey = "TacNet.TTS.enabled" + + init(enabled: Bool? = nil) { + self.enabled = enabled ?? UserDefaults.standard.object(forKey: Self.enabledKey) as? Bool ?? true + } + + var isEnabled: Bool { enabled } + + func setEnabled(_ enabled: Bool) { + self.enabled = enabled + UserDefaults.standard.set(enabled, forKey: Self.enabledKey) + } + + func speak(_ text: String, senderRole: String?) async { + guard enabled else { return } + + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + + pendingUtterances.append((text: trimmed, senderRole: senderRole)) + await processQueue() + } + + func stopSpeaking() async { + pendingUtterances.removeAll() + isSpeaking = false + await MainActor.run { TTSEngine.shared.stop() } + } + + private func processQueue() async { + guard !isSpeaking, let next = pendingUtterances.first else { return } + pendingUtterances.removeFirst() + isSpeaking = true + + let utteranceText: String + if let role = next.senderRole, !role.isEmpty { + utteranceText = "\(role) reports: \(next.text)" + } else { + utteranceText = next.text + } + + await MainActor.run { TTSEngine.shared.speakSync(utteranceText) } + await TTSEngine.shared.waitForCompletion() + isSpeaking = false + await processQueue() + } +} + +// MARK: - MainActor engine wrapping AVSpeechSynthesizer + +@MainActor +final class TTSEngine: NSObject, AVSpeechSynthesizerDelegate { + static let shared = TTSEngine() + + private let synthesizer = AVSpeechSynthesizer() + private var continuation: CheckedContinuation? + private var completionContinuation: CheckedContinuation? + + override init() { + super.init() + synthesizer.delegate = self + } + + func speakSync(_ text: String) { + let utterance = AVSpeechUtterance(string: text) + utterance.voice = AVSpeechSynthesisVoice(language: "en-US") + utterance.rate = 0.45 + utterance.pitchMultiplier = 1.0 + utterance.preUtteranceDelay = 0.1 + synthesizer.speak(utterance) + } + + func waitForCompletion() async { + await withCheckedContinuation { (continuation: CheckedContinuation) in + if !synthesizer.isSpeaking { + continuation.resume() + return + } + self.completionContinuation = continuation + } + } + + func stop() { + synthesizer.stopSpeaking(at: .immediate) + completionContinuation?.resume() + completionContinuation = nil + } + + nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) { + Task { @MainActor in + self.completionContinuation?.resume() + self.completionContinuation = nil + } + } + + nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) { + Task { @MainActor in + self.completionContinuation?.resume() + self.completionContinuation = nil + } + } +} diff --git a/TacNet/TacNetApp.swift b/TacNet/TacNetApp.swift new file mode 100644 index 00000000..2eaca8b2 --- /dev/null +++ b/TacNet/TacNetApp.swift @@ -0,0 +1,10 @@ +import SwiftUI + +@main +struct TacNetApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} diff --git a/TacNet/Utilities/CactusFunctionProbe.swift b/TacNet/Utilities/CactusFunctionProbe.swift new file mode 100644 index 00000000..f150dc9d --- /dev/null +++ b/TacNet/Utilities/CactusFunctionProbe.swift @@ -0,0 +1,11 @@ +import Foundation + +enum CactusFunctionProbe { + static func verifyCallableSymbols() -> Bool { + _ = cactusInit as (String, String?, Bool) throws -> CactusModelT + _ = cactusComplete as (CactusModelT, String, String?, String?, ((String, UInt32) -> Void)?, Data?) throws -> String + _ = cactusTranscribe as (CactusModelT, String?, String?, String?, ((String, UInt32) -> Void)?, Data?) throws -> String + _ = cactusDestroy as (CactusModelT) -> Void + return true + } +} diff --git a/TacNet/Utilities/FrameworkImportsProbe.swift b/TacNet/Utilities/FrameworkImportsProbe.swift new file mode 100644 index 00000000..89017aac --- /dev/null +++ b/TacNet/Utilities/FrameworkImportsProbe.swift @@ -0,0 +1,16 @@ +import Foundation +import CoreBluetooth +import AVFoundation +import CoreLocation +import SwiftData + +enum FrameworkImportsProbe { + static func touchFrameworkSymbols() { + _ = CBCentralManager.self + _ = AVAudioEngine.self + _ = CLLocationManager.self + if #available(iOS 17.0, *) { + _ = ModelContainer.self + } + } +} diff --git a/TacNet/Views/ContentView.swift b/TacNet/Views/ContentView.swift new file mode 100644 index 00000000..c7e3d211 --- /dev/null +++ b/TacNet/Views/ContentView.swift @@ -0,0 +1,3861 @@ +import SwiftUI +import Combine +import OSLog +import UniformTypeIdentifiers + +/// Small bundle of launch-argument-driven flags used by XCUITest to drive the app +/// deterministically without requiring real BLE, real model weights, or real network. +/// +/// Activated by passing launch arguments on `XCUIApplication.launchArguments` from the +/// UI test target. All flags are no-ops in normal production launches. +enum UITestMode { + /// Passing `--ui-test-skip-download` short-circuits the model download gate so UI + /// tests reach the onboarding/main screens immediately. + static var skipDownload: Bool { + ProcessInfo.processInfo.arguments.contains("--ui-test-skip-download") + } + + /// Passing `--ui-test-route=` replaces the root view with a dedicated host + /// for a specific screen that is otherwise unreachable in Simulator (e.g. PIN + /// entry, which requires a discovered BLE network). + static var route: String? { + value(forPrefix: "--ui-test-route=") + } + + /// Passing `--ui-test-download-fixture=` replaces the real download flow in + /// `AppBootstrapViewModel` with a deterministic fixture so UI tests can assert the + /// real bootstrap gate UI without pulling down the 6.7 GB production model. + /// + /// Supported fixtures: + /// * `"stuck"` — bootstrap stays at 0% progress indefinitely with no error so + /// the download gate remains visible and the retry button stays hidden. + /// * `"failfast"` — bootstrap unlocks near-instantaneously (equivalent to + /// `--ui-test-skip-download`), suitable for tests that just want to rush past + /// the gate. + static var downloadFixture: String? { + value(forPrefix: "--ui-test-download-fixture=") + } + + /// Passing `--ui-test-role=` seeds role-scoped UI hosts (e.g. the settings + /// host) with either `"organiser"` or `"participant"` state so role-gated + /// affordances can be verified without running a full network bring-up. + static var role: String? { + value(forPrefix: "--ui-test-role=") + } + + /// Passing `--ui-test-capture-logs` activates an in-app log buffer that records + /// every `[PTT]` NSLog emission and scans the unified logging system for system- + /// generated gesture-arbitration diagnostics (`Gesture: System gesture gate timed + /// out.`). The buffer's contents are exposed via the + /// `tacnet.debug.logBuffer` accessibility identifier on the Main tab so the real + /// SwiftUI Main tab can be driven by XCUITest and the console evidence required by + /// VAL-PTT-002 / VAL-PTT-003 can be harvested without requiring `xcrun simctl + /// spawn log stream` (which is unavailable to iOS XCUITest processes). + static var captureLogs: Bool { + ProcessInfo.processInfo.arguments.contains("--ui-test-capture-logs") + } + + /// Passing `--ui-test-mesh-peers=` seeds `BluetoothMeshService` with N fake + /// connected peer IDs at boot so the real `MainViewModel` takes the connected-path + /// (happy) branch when PTT is pressed, without requiring real BLE. A value of `0` + /// (or omission) preserves the disconnected-path (gated) branch. Production-code + /// untouched; only the UI-test seeding helper activates. + static var meshPeerCount: Int { + guard let raw = value(forPrefix: "--ui-test-mesh-peers="), + let count = Int(raw), + count >= 0 else { + return 0 + } + return count + } + + private static func value(forPrefix prefix: String) -> String? { + for arg in ProcessInfo.processInfo.arguments where arg.hasPrefix(prefix) { + return String(arg.dropFirst(prefix.count)) + } + return nil + } +} + +/// In-app log capture used ONLY when the app is launched with +/// `--ui-test-capture-logs` by an XCUITest. Collects every `[PTT]` line emitted by +/// `pttLog(_:)` and (on demand) scans the unified logging system for UIKit-generated +/// `Gesture: System gesture gate timed out.` messages, which appear when SwiftUI's +/// gesture arbitration loses the PTT long-press to an ancestor recognizer (tab swipe +/// / nav back-edge / scroll). The buffer contents are exposed via a SwiftUI `Text` +/// with accessibility identifier `tacnet.debug.logBuffer`, and a `Button` with +/// identifier `tacnet.debug.refreshLogBuffer` forces a fresh OSLog scan so XCUITest +/// can read the post-interaction snapshot deterministically. +@MainActor +final class UITestLogCapture: ObservableObject { + static let shared = UITestLogCapture() + + /// Distinct prefix used for system-sourced log lines harvested from OSLogStore so + /// XCUITest can differentiate them from `pttLog(_:)` app-emitted lines. + static let systemLinePrefix = "[SYSTEM]" + + private let startDate = Date() + + @Published private(set) var lines: [String] = [] + + func append(_ line: String) { + lines.append(line) + } + + var snapshotText: String { + if lines.isEmpty { return "(empty)" } + return lines.joined(separator: "\n") + } + + /// Scans the unified logging system for entries emitted in the current process + /// since capture began, specifically looking for iOS gesture-arbitration timeout + /// diagnostics. Any matches are appended (deduplicated) to the buffer. Best-effort + /// — `OSLogStore` requires iOS 15+ and can fail if the process lacks log-read + /// permission; on failure the buffer simply remains unchanged. + func scanSystemLog() { + guard #available(iOS 15.0, *) else { return } + do { + let store = try OSLogStore(scope: .currentProcessIdentifier) + let position = store.position(date: startDate) + let entries = try store.getEntries(at: position) + for case let entry as OSLogEntryLog in entries { + let composed = entry.composedMessage + if composed.contains("Gesture: System gesture gate timed out") { + let annotated = "\(Self.systemLinePrefix) \(composed)" + if !lines.contains(annotated) { + lines.append(annotated) + } + } + } + } catch { + // Best-effort; OSLogStore may be unavailable in some simulator runs. + } + } + + /// For XCUITest teardown / reset between tests within a single process. + func reset() { + lines.removeAll() + } +} + +/// Emit a `[PTT]`-prefixed log line via `NSLog` (preserved for on-device debugging — +/// see Mission Boundaries) and, when the app is running under +/// `--ui-test-capture-logs`, also append the literal line to +/// `UITestLogCapture.shared` so XCUITest can read the captured console as +/// accessibility-exposed text without needing host-side log streaming. +/// +/// Production behaviour when not under UI-test capture: byte-for-byte identical to +/// the pre-existing `NSLog("[PTT] …")` call sites. +func pttLog(_ message: String) { + NSLog("%@", message) + if UITestMode.captureLogs { + Task { @MainActor in + UITestLogCapture.shared.append(message) + } + } +} + +struct ContentView: View { + @Environment(\.scenePhase) private var scenePhase + @StateObject private var bootstrapViewModel = AppBootstrapViewModel() + @StateObject private var treeBuilderViewModel = TreeBuilderViewModel( + createdBy: ProcessInfo.processInfo.hostName + ) + @StateObject private var appNetworkCoordinator = AppNetworkCoordinator() + @State private var onboardingRoute: OnboardingRoute = .welcome + + private enum OnboardingRoute { + case welcome + case createNetwork + case joinNetwork + case roleSelection + case main + } + + var body: some View { + Group { + if let route = UITestMode.route { + UITestRouteHost(route: route) + } else if bootstrapViewModel.isDownloadComplete { + mainAppShell + } else { + downloadGate + } + } + .task { + bootstrapViewModel.startIfNeeded() + } + .onChange(of: scenePhase) { _, phase in + appNetworkCoordinator.handleScenePhase(phase) + } + } + + private var mainAppShell: some View { + NavigationStack { + switch onboardingRoute { + case .welcome: + WelcomeView { + onboardingRoute = .createNetwork + } onJoinNetwork: { + onboardingRoute = .joinNetwork + } + + case .createNetwork: + TreeBuilderView( + viewModel: treeBuilderViewModel, + onBack: { + onboardingRoute = .welcome + }, + onPublishNetwork: { config in + appNetworkCoordinator.publish(networkConfig: config) + appNetworkCoordinator.activateRoleClaiming(with: config) + onboardingRoute = .roleSelection + } + ) + + case .joinNetwork: + JoinNetworkFlowView( + discoveryService: appNetworkCoordinator.discoveryService, + treeSyncService: appNetworkCoordinator.treeSyncService, + onJoined: { joinedConfig in + appNetworkCoordinator.activateRoleClaiming(with: joinedConfig) + onboardingRoute = .roleSelection + } + ) { + onboardingRoute = .welcome + } + + case .roleSelection: + RoleSelectionView( + roleClaimService: appNetworkCoordinator.roleClaimService, + treeSyncService: appNetworkCoordinator.treeSyncService, + onRoleClaimed: { + onboardingRoute = .main + } + ) { + onboardingRoute = .welcome + } + + case .main: + TacNetTabShellView( + mainViewModel: appNetworkCoordinator.mainViewModel, + treeViewModel: appNetworkCoordinator.treeViewModel, + dataFlowViewModel: appNetworkCoordinator.dataFlowViewModel, + settingsViewModel: appNetworkCoordinator.settingsViewModel, + afterActionReviewViewModel: appNetworkCoordinator.afterActionReviewViewModel, + onBackToRoleSelection: { + onboardingRoute = .roleSelection + } + ) + } + } + } + + private var downloadGate: some View { + VStack(spacing: 16) { + Image(systemName: "square.and.arrow.down") + .font(.system(size: 48)) + .foregroundStyle(Color.accentColor) + + Text("Preparing On-Device AI Model") + .font(.title3) + .fontWeight(.semibold) + .accessibilityIdentifier("tacnet.downloadGate.title") + + Text(bootstrapViewModel.modelName) + .font(.subheadline) + .foregroundStyle(.secondary) + + ProgressView(value: bootstrapViewModel.downloadProgress, total: 1) + .progressViewStyle(.linear) + .frame(maxWidth: 260) + .accessibilityIdentifier("tacnet.downloadGate.progressBar") + + VStack(spacing: 4) { + Text(bootstrapViewModel.progressLabel) + .font(.headline.monospacedDigit()) + + Text(bootstrapViewModel.byteProgressLabel) + .font(.footnote.monospacedDigit()) + .foregroundStyle(.secondary) + } + + if let errorMessage = bootstrapViewModel.errorMessage { + Text(errorMessage) + .font(.footnote) + .foregroundStyle(.red) + .multilineTextAlignment(.center) + .padding(.horizontal) + + Button("Retry Download") { + bootstrapViewModel.retry() + } + .buttonStyle(.borderedProminent) + .accessibilityIdentifier("tacnet.downloadGate.retryButton") + } else { + Text("TacNet features are locked until model download completes.") + .font(.footnote) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + .accessibilityIdentifier("tacnet.downloadGate.lockedCopy") + } + } + .padding() + .accessibilityElement(children: .contain) + .accessibilityIdentifier("tacnet.downloadGate.root") + } +} + +/// Hosts a dedicated UI for screens that are unreachable without BLE/network during +/// UI tests. Triggered via the `--ui-test-route=` launch argument. +private struct UITestRouteHost: View { + let route: String + + var body: some View { + switch route { + case "pin-entry": + UITestPinEntryHost() + case "settings": + UITestSettingsHost(role: UITestMode.role ?? "participant") + case "main-ptt": + UITestMainPTTHost() + default: + Text("Unknown UI test route: \(route)") + .accessibilityIdentifier("tacnet.uiTestRoute.unknown") + } + } +} + +/// Hosts a stripped-down `PTTButton` wired to on-screen counters so XCUITest can verify +/// that press/release gestures dispatch exactly once per physical press, without depending +/// on a real `MainViewModel`, mesh service, or audio capture. Triggered via +/// `--ui-test-route=main-ptt`. The counters are published as distinct accessibility +/// identifiers (`tacnet.main.pttDebugBegan`, `tacnet.main.pttDebugEnded`) so assertions can +/// read them directly. +private struct UITestMainPTTHost: View { + @State private var pressBeganCount: Int = 0 + @State private var pressEndedCount: Int = 0 + + var body: some View { + VStack(spacing: 20) { + Text("Began:\(pressBeganCount)") + .accessibilityIdentifier("tacnet.main.pttDebugBegan") + Text("Ended:\(pressEndedCount)") + .accessibilityIdentifier("tacnet.main.pttDebugEnded") + + PTTButton( + title: "Hold to Talk", + symbol: "mic.fill", + color: .blue, + isInteractionDisabled: false, + isVisuallyDimmed: false, + onPressBegan: { + pressBeganCount += 1 + NSLog("[PTT] UITest host press-began count=%d", pressBeganCount) + }, + onPressEnded: { + pressEndedCount += 1 + NSLog("[PTT] UITest host press-ended count=%d", pressEndedCount) + } + ) + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("tacnet.uiTestRoute.mainPTT.root") + } +} + +/// Hosts the real `SettingsView` against a seeded `RoleClaimService` so UI tests can +/// verify that role-gated affordances (edit tree, promote, release role) appear only +/// for the appropriate role. Triggered via `--ui-test-route=settings` and the +/// `--ui-test-role=organiser|participant` launch argument. +private struct UITestSettingsHost: View { + let role: String + + @StateObject private var roleClaimService: RoleClaimService + @StateObject private var settingsViewModel: SettingsViewModel + @StateObject private var afterActionReviewViewModel: AfterActionReviewViewModel + private let meshService: BluetoothMeshService + private let treeSyncService: TreeSyncService + + init(role: String) { + self.role = role + + let organiserDeviceID = "ui-test-organiser" + let participantDeviceID = "ui-test-participant" + let localDeviceID = (role == "organiser") ? organiserDeviceID : participantDeviceID + + let meshService = BluetoothMeshService() + self.meshService = meshService + + let treeSyncService = TreeSyncService(meshService: meshService, configStore: nil) + self.treeSyncService = treeSyncService + + // Seeded network: organiser owns the root "Commander" node and the child + // "Alpha" is held by the participant. This shape gives the organiser a + // promotable participant (for the Promote button) and guarantees the + // participant has an active claim to release. + let alphaNode = TreeNode( + id: "ui-test-alpha", + label: "Alpha", + claimedBy: participantDeviceID, + children: [] + ) + let rootNode = TreeNode( + id: "ui-test-root", + label: "Commander", + claimedBy: organiserDeviceID, + children: [alphaNode] + ) + let seededConfig = NetworkConfig( + networkName: "UITest Network", + networkID: UUID(uuidString: "11111111-1111-1111-1111-111111111111") ?? UUID(), + createdBy: organiserDeviceID, + pinHash: nil, + encryptedSessionKey: nil, + version: 1, + tree: rootNode + ) + treeSyncService.setLocalConfig(seededConfig) + + let roleClaim = RoleClaimService( + meshService: meshService, + treeSyncService: treeSyncService, + localDeviceID: localDeviceID + ) + _roleClaimService = StateObject(wrappedValue: roleClaim) + _settingsViewModel = StateObject( + wrappedValue: SettingsViewModel(roleClaimService: roleClaim) + ) + _afterActionReviewViewModel = StateObject( + wrappedValue: AfterActionReviewViewModel(store: InMemoryAfterActionReviewStore()) + ) + } + + var body: some View { + NavigationStack { + SettingsView( + viewModel: settingsViewModel, + afterActionReviewViewModel: afterActionReviewViewModel, + onReleaseRole: {} + ) + } + .accessibilityIdentifier("tacnet.uiTestRoute.settings.\(role)") + } +} + +private struct UITestPinEntryHost: View { + @State private var pin: String = "" + @State private var errorMessage: String? + @State private var lastSubmittedPin: String? + + private let network = DiscoveredNetwork( + peerID: UUID(uuidString: "00000000-0000-0000-0000-000000000001") ?? UUID(), + networkID: UUID(uuidString: "00000000-0000-0000-0000-000000000002") ?? UUID(), + networkName: "Test Network", + openSlotCount: 3, + requiresPIN: true + ) + + var body: some View { + NavigationStack { + VStack(spacing: 8) { + PinEntryView( + network: network, + pin: $pin, + errorMessage: errorMessage, + isJoining: false, + onSubmit: { + lastSubmittedPin = pin + }, + onCancel: { + pin = "" + errorMessage = nil + } + ) + + if let lastSubmittedPin { + Text("Submitted: \(lastSubmittedPin)") + .font(.footnote) + .foregroundStyle(.green) + .accessibilityIdentifier("tacnet.pin.submittedValue") + } + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("tacnet.pin.host.root") + } + } +} + +struct WelcomeView: View { + let onCreateNetwork: () -> Void + let onJoinNetwork: () -> Void + + var body: some View { + VStack(spacing: 20) { + Spacer(minLength: 24) + + Image(systemName: "person.3.sequence.fill") + .font(.system(size: 54)) + .foregroundStyle(Color.accentColor) + + Text("Welcome to TacNet") + .font(.title2) + .fontWeight(.bold) + + Text("Set up a command tree as organiser or join an existing network.") + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + + VStack(spacing: 12) { + Button("Create Network", action: onCreateNetwork) + .buttonStyle(.borderedProminent) + .frame(maxWidth: .infinity) + .accessibilityIdentifier("tacnet.welcome.createNetworkButton") + + Button("Join Network", action: onJoinNetwork) + .buttonStyle(.bordered) + .frame(maxWidth: .infinity) + .accessibilityIdentifier("tacnet.welcome.joinNetworkButton") + } + .padding(.horizontal) + + Spacer() + } + .navigationTitle("Onboarding") + // Container-only identifier (does not override child Buttons' identifiers). + .accessibilityElement(children: .contain) + .accessibilityIdentifier("tacnet.welcome.root") + } +} + +struct TreeBuilderView: View { + @ObservedObject var viewModel: TreeBuilderViewModel + let onBack: (() -> Void)? + let onPublishNetwork: ((NetworkConfig) -> Void)? + + @State private var networkNameDraft: String + @State private var pinDraft: String + @State private var selectedNodeID: String? + @State private var renameDraft: String + @State private var newChildLabelDraft: String = "" + @State private var isPublished = false + + init( + viewModel: TreeBuilderViewModel, + onBack: (() -> Void)? = nil, + onPublishNetwork: ((NetworkConfig) -> Void)? = nil + ) { + _viewModel = ObservedObject(wrappedValue: viewModel) + self.onBack = onBack + self.onPublishNetwork = onPublishNetwork + _networkNameDraft = State(initialValue: viewModel.networkConfig.networkName) + _pinDraft = State(initialValue: "") + _selectedNodeID = State(initialValue: viewModel.networkConfig.tree.id) + _renameDraft = State(initialValue: viewModel.networkConfig.tree.label) + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + GroupBox("Network Settings") { + VStack(alignment: .leading, spacing: 10) { + TextField("Network name", text: $networkNameDraft) + .textFieldStyle(.roundedBorder) + .accessibilityIdentifier("tacnet.treeBuilder.networkNameField") + + SecureField("Optional PIN", text: $pinDraft) + .textFieldStyle(.roundedBorder) + .accessibilityIdentifier("tacnet.treeBuilder.pinField") + + HStack { + Button("Apply Settings") { + _ = viewModel.updateNetworkName(networkNameDraft) + _ = viewModel.updatePin(pinDraft) + } + .buttonStyle(.borderedProminent) + .accessibilityIdentifier("tacnet.treeBuilder.applySettingsButton") + + Text("Version \(viewModel.currentVersion)") + .font(.caption) + .foregroundStyle(.secondary) + } + + HStack(spacing: 10) { + Button(isPublished ? "Update BLE Publish" : "Publish Network") { + onPublishNetwork?(viewModel.networkConfig) + isPublished = true + } + .buttonStyle(.bordered) + .disabled(onPublishNetwork == nil) + .accessibilityIdentifier("tacnet.treeBuilder.publishButton") + + if isPublished { + Label("Advertising live", systemImage: "dot.radiowaves.left.and.right") + .font(.caption) + .foregroundStyle(.green) + } + } + + Text("Open slots: \(viewModel.networkConfig.openSlotCount)") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + GroupBox("Tree") { + VStack(alignment: .leading, spacing: 10) { + if viewModel.isTreeEmpty { + Text("Tree is empty. Add a child node to start building the hierarchy.") + .font(.footnote) + .foregroundStyle(.secondary) + } + + TreeNodeTreeView( + node: viewModel.networkConfig.tree, + depth: 0, + selectedNodeID: selectedNodeID, + onDropNode: handleTreeNodeDrop + ) { node in + selectedNodeID = node.id + renameDraft = node.label + } + } + } + + GroupBox("Edit Selected Node") { + VStack(alignment: .leading, spacing: 10) { + Text(selectedNodeSummary) + .font(.footnote) + .foregroundStyle(.secondary) + + TextField("Rename selected node", text: $renameDraft) + .textFieldStyle(.roundedBorder) + .accessibilityIdentifier("tacnet.treeBuilder.renameField") + + HStack { + Button("Rename") { + guard let selectedNodeID else { return } + _ = viewModel.renameNode(nodeID: selectedNodeID, newLabel: renameDraft) + } + .buttonStyle(.bordered) + .disabled(selectedNodeID == nil) + .accessibilityIdentifier("tacnet.treeBuilder.renameButton") + + Button("Remove", role: .destructive) { + guard let selectedNodeID else { return } + if viewModel.removeNode(nodeID: selectedNodeID) { + let root = viewModel.networkConfig.tree + self.selectedNodeID = root.id + renameDraft = root.label + } + } + .buttonStyle(.bordered) + .disabled(selectedNodeID == nil) + .accessibilityIdentifier("tacnet.treeBuilder.removeButton") + } + + TextField("New child label", text: $newChildLabelDraft) + .textFieldStyle(.roundedBorder) + .accessibilityIdentifier("tacnet.treeBuilder.newChildField") + + HStack { + Button("Add Child") { + let parentID = selectedNodeID ?? viewModel.networkConfig.tree.id + guard let created = viewModel.addNode(parentID: parentID, label: newChildLabelDraft) else { + return + } + selectedNodeID = created.id + renameDraft = created.label + newChildLabelDraft = "" + } + .buttonStyle(.borderedProminent) + .accessibilityIdentifier("tacnet.treeBuilder.addChildButton") + + Button("Clear Tree", role: .destructive) { + guard viewModel.clearTree() else { return } + let root = viewModel.networkConfig.tree + selectedNodeID = root.id + renameDraft = root.label + } + .buttonStyle(.bordered) + .accessibilityIdentifier("tacnet.treeBuilder.clearTreeButton") + } + } + } + + GroupBox("BLE Distribution JSON") { + Text(viewModel.serializedTreeJSON(prettyPrinted: true) ?? "{}") + .font(.caption.monospaced()) + .textSelection(.enabled) + } + } + .padding() + } + .navigationTitle("Tree Builder") + .accessibilityElement(children: .contain) + .accessibilityIdentifier("tacnet.treeBuilder.root") + .toolbar { + if let onBack { + ToolbarItem(placement: .navigationBarLeading) { + Button("Back", action: onBack) + .accessibilityIdentifier("tacnet.treeBuilder.backButton") + } + } + } + .onChange(of: selectedNodeID) { _, newValue in + guard + let newValue, + let node = viewModel.node(withID: newValue) + else { + return + } + renameDraft = node.label + } + .onReceive(viewModel.$networkConfig) { updatedConfig in + guard isPublished else { + return + } + onPublishNetwork?(updatedConfig) + } + } + + private func handleTreeNodeDrop(draggedNodeID: String, onto targetNodeID: String) -> Bool { + guard draggedNodeID != targetNodeID else { + return false + } + + let treeSnapshot = viewModel.networkConfig.tree + let sourceParentID = TreeHelpers.parent(of: draggedNodeID, in: treeSnapshot)?.id + let targetParentID = TreeHelpers.parent(of: targetNodeID, in: treeSnapshot)?.id + + let didApplyDrop: Bool + if let sourceParentID, sourceParentID == targetParentID { + didApplyDrop = viewModel.reorderNode(nodeID: draggedNodeID, beforeSiblingID: targetNodeID) + } else { + didApplyDrop = viewModel.moveNode(nodeID: draggedNodeID, newParentID: targetNodeID) + } + + guard didApplyDrop else { + return false + } + + selectedNodeID = draggedNodeID + if let movedNode = viewModel.node(withID: draggedNodeID) { + renameDraft = movedNode.label + } + return true + } + + private var selectedNodeSummary: String { + guard + let selectedNodeID, + let node = viewModel.node(withID: selectedNodeID) + else { + return "No node selected." + } + + let nodeLabel = node.label.isEmpty ? "(unnamed)" : node.label + return "Selected: \(nodeLabel) • \(selectedNodeID)" + } +} + +private struct TreeNodeTreeView: View { + let node: TreeNode + let depth: Int + let selectedNodeID: String? + let onDropNode: (String, String) -> Bool + let onSelect: (TreeNode) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Button { + onSelect(node) + } label: { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(node.label.isEmpty ? "(unnamed node)" : node.label) + .font(.subheadline.weight(.medium)) + Text(node.claimedBy ?? "Available") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer(minLength: 8) + Text(node.id) + .font(.caption2.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) + } + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(rowBackground) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .padding(.leading, CGFloat(depth) * 16) + .onDrag { + onSelect(node) + return NSItemProvider(object: node.id as NSString) + } + .onDrop(of: [UTType.plainText], isTargeted: nil) { providers in + performDrop(providers: providers, targetNodeID: node.id) + } + + ForEach(node.children, id: \.id) { child in + TreeNodeTreeView( + node: child, + depth: depth + 1, + selectedNodeID: selectedNodeID, + onDropNode: onDropNode, + onSelect: onSelect + ) + } + } + } + + private func performDrop(providers: [NSItemProvider], targetNodeID: String) -> Bool { + guard let provider = providers.first(where: { $0.canLoadObject(ofClass: NSString.self) }) else { + return false + } + + _ = provider.loadObject(ofClass: NSString.self) { object, _ in + guard let sourceNodeID = object as? NSString else { + return + } + DispatchQueue.main.async { + _ = onDropNode(sourceNodeID as String, targetNodeID) + } + } + return true + } + + private var rowBackground: Color { + selectedNodeID == node.id ? Color.accentColor.opacity(0.18) : Color.secondary.opacity(0.12) + } +} + +private struct JoinNetworkFlowView: View { + @ObservedObject var discoveryService: NetworkDiscoveryService + @ObservedObject var treeSyncService: TreeSyncService + let onJoined: ((NetworkConfig) -> Void)? + let onBack: () -> Void + + @State private var selectedPINNetwork: DiscoveredNetwork? + @State private var pinDraft = "" + @State private var joinErrorMessage: String? + @State private var isJoining = false + @State private var joinedConfig: NetworkConfig? + + var body: some View { + Group { + if let joinedConfig { + joinedState(config: joinedConfig) + } else if let selectedPINNetwork { + pinEntryState(network: selectedPINNetwork) + } else { + NetworkScanView( + discoveryService: discoveryService, + isJoining: isJoining, + joinErrorMessage: joinErrorMessage, + onSelectNetwork: handleNetworkSelection, + onBack: onBack + ) + } + } + .navigationTitle("Join") + .onDisappear { + discoveryService.stopScanning() + } + } + + @ViewBuilder + private func pinEntryState(network: DiscoveredNetwork) -> some View { + PinEntryView( + network: network, + pin: $pinDraft, + errorMessage: joinErrorMessage, + isJoining: isJoining, + onSubmit: { + Task { + await join(network: network, pin: pinDraft) + } + }, + onCancel: { + selectedPINNetwork = nil + pinDraft = "" + joinErrorMessage = nil + } + ) + } + + @ViewBuilder + private func joinedState(config: NetworkConfig) -> some View { + VStack(alignment: .leading, spacing: 14) { + Label("Joined \(config.networkName)", systemImage: "checkmark.seal.fill") + .font(.headline) + .foregroundStyle(.green) + + Text("Version \(config.version) • Open slots \(config.openSlotCount)") + .font(.subheadline) + .foregroundStyle(.secondary) + + GroupBox("Received Tree JSON") { + ScrollView { + Text(prettyPrintedJSON(for: config) ?? "{}") + .frame(maxWidth: .infinity, alignment: .leading) + .font(.caption.monospaced()) + .textSelection(.enabled) + } + .frame(maxHeight: 260) + } + + HStack(spacing: 10) { + Button("Join Another Network") { + joinedConfig = nil + selectedPINNetwork = nil + pinDraft = "" + joinErrorMessage = nil + discoveryService.startScanning(timeout: 10) + } + .buttonStyle(.bordered) + + Button("Back", action: onBack) + .buttonStyle(.borderedProminent) + } + } + .padding() + } + + private func handleNetworkSelection(_ network: DiscoveredNetwork) { + joinErrorMessage = nil + NSLog("[Join] Tapped network '%@' (peerID: %@, requiresPIN: %@)", + network.networkName, network.peerID.uuidString, + network.requiresPIN ? "yes" : "no") + + if network.requiresPIN { + NSLog("[Join] PIN required — showing PIN entry") + selectedPINNetwork = network + pinDraft = "" + return + } + + Task { + await join(network: network, pin: nil) + } + } + + private func join(network: DiscoveredNetwork, pin: String?) async { + guard !isJoining else { + NSLog("[Join] ⚠️ join() called while already joining — ignored") + return + } + + NSLog("[Join] Starting join for network '%@' (peerID: %@, pin: %@)", + network.networkName, network.peerID.uuidString, + pin == nil ? "none" : "provided") + isJoining = true + defer { isJoining = false } + + do { + NSLog("[Join] Fetching tree config via GATT from peer %@…", network.peerID.uuidString) + let joined = try await treeSyncService.join(network: network, pin: pin) + NSLog("[Join] ✅ Successfully joined '%@' (networkID: %@, version: %d)", + joined.networkName, joined.networkID.uuidString, joined.version) + if let onJoined { + onJoined(joined) + } else { + joinedConfig = joined + } + selectedPINNetwork = nil + joinErrorMessage = nil + discoveryService.stopScanning() + } catch let error as TreeSyncJoinError { + NSLog("[Join] ❌ Join failed with TreeSyncJoinError: %@", String(describing: error)) + joinErrorMessage = joinErrorMessage(for: error) + } catch { + NSLog("[Join] ❌ Join failed with error: %@", error.localizedDescription) + joinErrorMessage = error.localizedDescription + } + } + + private func joinErrorMessage(for error: TreeSyncJoinError) -> String { + switch error { + case .treeConfigUnavailable: + return "Unable to fetch tree data from organiser. Try scanning again." + case .networkMismatch: + return "Discovered network details changed. Please rescan." + case .pinRequired: + return "PIN is required to join this network." + case .invalidPIN: + return "Incorrect PIN. Join blocked." + } + } + + private func prettyPrintedJSON(for config: NetworkConfig) -> String? { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + guard let data = try? encoder.encode(config.tree) else { + return nil + } + return String(data: data, encoding: .utf8) + } +} + +private struct NetworkScanView: View { + @ObservedObject var discoveryService: NetworkDiscoveryService + let isJoining: Bool + let joinErrorMessage: String? + let onSelectNetwork: (DiscoveredNetwork) -> Void + let onBack: () -> Void + + var body: some View { + ZStack { + VStack(spacing: 12) { + // Error banner — shown when a join attempt fails + if let errorMessage = joinErrorMessage { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.red) + Text(errorMessage) + .font(.footnote) + .foregroundStyle(.red) + .multilineTextAlignment(.leading) + Spacer() + } + .padding(10) + .background(Color.red.opacity(0.10), in: RoundedRectangle(cornerRadius: 8)) + .padding(.horizontal) + .accessibilityIdentifier("tacnet.scan.errorBanner") + } + + if discoveryService.nearbyNetworks.isEmpty { + VStack(spacing: 8) { + if discoveryService.isScanning { + ProgressView() + } + + Text(discoveryService.isScanning ? "Scanning for nearby TacNet networks…" : "No networks found.") + .font(.footnote) + .foregroundStyle(.secondary) + .accessibilityIdentifier("tacnet.scan.emptyStateLabel") + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .accessibilityIdentifier("tacnet.scan.emptyState") + } else { + List(discoveryService.nearbyNetworks) { network in + Button { + onSelectNetwork(network) + } label: { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 3) { + Text(network.networkName) + .font(.headline) + .foregroundStyle(.primary) + Text("Open slots: \(network.openSlotCount)") + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + Image(systemName: network.requiresPIN ? "lock.fill" : "lock.open.fill") + .foregroundStyle(network.requiresPIN ? .orange : .green) + } + } + .disabled(isJoining) + } + .listStyle(.plain) + } + + HStack(spacing: 12) { + Button("Rescan (10s)") { + discoveryService.startScanning(timeout: 10) + } + .buttonStyle(.bordered) + .disabled(isJoining) + .accessibilityIdentifier("tacnet.scan.rescanButton") + + Button("Back", action: onBack) + .buttonStyle(.borderedProminent) + .disabled(isJoining) + .accessibilityIdentifier("tacnet.scan.backButton") + } + .padding(.bottom, 8) + } + .padding(.horizontal) + + // Joining overlay — covers the list while GATT fetch is in-flight + if isJoining { + Color.black.opacity(0.35) + .ignoresSafeArea() + VStack(spacing: 14) { + ProgressView() + .scaleEffect(1.4) + Text("Connecting…") + .font(.headline) + .foregroundStyle(.white) + } + .padding(28) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 14)) + } + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("tacnet.scan.root") + .task { + discoveryService.startScanning(timeout: 10) + } + } +} + +private struct PinEntryView: View { + let network: DiscoveredNetwork + @Binding var pin: String + let errorMessage: String? + let isJoining: Bool + let onSubmit: () -> Void + let onCancel: () -> Void + + var body: some View { + VStack(spacing: 14) { + Image(systemName: "lock.shield") + .font(.system(size: 40)) + .foregroundStyle(.orange) + + Text("Enter PIN for \(network.networkName)") + .font(.headline) + .multilineTextAlignment(.center) + .accessibilityIdentifier("tacnet.pin.title") + + SecureField("Network PIN", text: $pin) + .textFieldStyle(.roundedBorder) + .textContentType(.oneTimeCode) + .keyboardType(.numberPad) + .accessibilityIdentifier("tacnet.pin.field") + + if let errorMessage { + Text(errorMessage) + .font(.footnote) + .foregroundStyle(.red) + .multilineTextAlignment(.center) + .accessibilityIdentifier("tacnet.pin.errorLabel") + } + + HStack(spacing: 10) { + Button("Cancel", action: onCancel) + .buttonStyle(.bordered) + .accessibilityIdentifier("tacnet.pin.cancelButton") + + Button(isJoining ? "Joining…" : "Join Network", action: onSubmit) + .buttonStyle(.borderedProminent) + .disabled(isJoining) + .accessibilityIdentifier("tacnet.pin.submitButton") + } + } + .padding() + .accessibilityElement(children: .contain) + .accessibilityIdentifier("tacnet.pin.root") + } +} + +private struct RoleSelectionView: View { + @ObservedObject var roleClaimService: RoleClaimService + @ObservedObject var treeSyncService: TreeSyncService + let onRoleClaimed: () -> Void + let onBack: () -> Void + + @State private var statusMessage: String? + + var body: some View { + VStack(spacing: 12) { + if let config = treeSyncService.localConfig { + Text(config.networkName) + .font(.headline) + + Text("Tap an open node to claim your role.") + .font(.footnote) + .foregroundStyle(.secondary) + + List(flattenedTree(from: config.tree)) { node in + Button { + handleClaimTap(nodeID: node.id) + } label: { + HStack(spacing: 10) { + VStack(alignment: .leading, spacing: 3) { + Text(node.label.isEmpty ? "(unnamed node)" : node.label) + .font(.subheadline.weight(.medium)) + Text(claimStatusText(claimedBy: node.claimedBy)) + .font(.caption) + .foregroundStyle(claimStatusColor(claimedBy: node.claimedBy)) + } + + Spacer(minLength: 8) + Text(node.id) + .font(.caption2.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) + } + .padding(.vertical, 4) + .padding(.leading, CGFloat(node.depth) * 16) + } + .buttonStyle(.plain) + .disabled(node.claimedBy != nil && node.claimedBy != roleClaimService.localNodeIdentity) + .accessibilityIdentifier("tacnet.roleSelection.row.\(node.id)") + } + .listStyle(.plain) + .accessibilityIdentifier("tacnet.roleSelection.list") + + if let rejection = roleClaimService.lastClaimRejection { + Text(rejectionMessage(for: rejection)) + .font(.footnote) + .foregroundStyle(.red) + } + + if let statusMessage { + Text(statusMessage) + .font(.footnote) + .foregroundStyle(.secondary) + } + + HStack(spacing: 10) { + Button("Release Role") { + let result = roleClaimService.releaseActiveClaim() + switch result { + case .released(let nodeID): + statusMessage = "Released \(nodeID)." + case .noActiveClaim: + statusMessage = "No claimed role to release." + default: + statusMessage = nil + } + } + .buttonStyle(.bordered) + .disabled(roleClaimService.activeClaimNodeID == nil) + .accessibilityIdentifier("tacnet.roleSelection.releaseButton") + + Button("Back", action: onBack) + .buttonStyle(.borderedProminent) + .accessibilityIdentifier("tacnet.roleSelection.backButton") + } + .padding(.bottom, 4) + } else { + Spacer() + Text("No tree available yet. Join or publish a network first.") + .font(.footnote) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + Button("Back", action: onBack) + .buttonStyle(.borderedProminent) + Spacer() + } + } + .padding(.horizontal) + .navigationTitle("Role Selection") + .accessibilityElement(children: .contain) + .accessibilityIdentifier("tacnet.roleSelection.root") + } + + private func handleClaimTap(nodeID: String) { + let result = roleClaimService.claim(nodeID: nodeID) + switch result { + case .claimed(let claimedNodeID): + statusMessage = "Claimed \(claimedNodeID)." + onRoleClaimed() + case .rejected(let reason): + statusMessage = rejectionMessage(for: reason) + case .unavailable: + statusMessage = "Network config unavailable." + default: + statusMessage = nil + } + } + + private func claimStatusText(claimedBy: String?) -> String { + guard let claimedBy else { + return "Open" + } + + if claimedBy == roleClaimService.localNodeIdentity { + return "Claimed by you" + } + + return "Claimed by \(claimedBy)" + } + + private func claimStatusColor(claimedBy: String?) -> Color { + guard let claimedBy else { + return .green + } + return claimedBy == roleClaimService.localNodeIdentity ? .blue : .secondary + } + + private func rejectionMessage(for reason: ClaimRejectionReason) -> String { + switch reason { + case .alreadyClaimed: + return "Claim rejected: node already claimed." + case .organiserWins: + return "Claim rejected: organiser wins conflict resolution." + case .nodeNotFound: + return "Claim rejected: selected node no longer exists." + } + } + + private func flattenedTree(from root: TreeNode) -> [FlatTreeNode] { + var nodes: [FlatTreeNode] = [] + append(node: root, depth: 0, into: &nodes) + return nodes + } + + private func append(node: TreeNode, depth: Int, into nodes: inout [FlatTreeNode]) { + nodes.append( + FlatTreeNode( + id: node.id, + label: node.label, + depth: depth, + claimedBy: node.claimedBy + ) + ) + for child in node.children { + append(node: child, depth: depth + 1, into: &nodes) + } + } + + private struct FlatTreeNode: Identifiable { + let id: String + let label: String + let depth: Int + let claimedBy: String? + } +} + +enum TacNetTab: String, CaseIterable, Identifiable { + case main + case treeView + case dataFlow + case settings + + var id: String { rawValue } + + var title: String { + switch self { + case .main: + return "Main" + case .treeView: + return "Tree View" + case .dataFlow: + return "Data Flow" + case .settings: + return "Settings" + } + } + + var systemImage: String { + switch self { + case .main: + return "dot.radiowaves.left.and.right" + case .treeView: + return "point.3.filled.connected.trianglepath.dotted" + case .dataFlow: + return "arrow.triangle.branch" + case .settings: + return "gearshape" + } + } +} + +private struct TacNetTabShellView: View { + @ObservedObject var mainViewModel: MainViewModel + @ObservedObject var treeViewModel: TreeViewModel + @ObservedObject var dataFlowViewModel: DataFlowViewModel + @ObservedObject var settingsViewModel: SettingsViewModel + @ObservedObject var afterActionReviewViewModel: AfterActionReviewViewModel + let onBackToRoleSelection: () -> Void + + @State private var selectedTab: TacNetTab = .main + + var body: some View { + TabView(selection: $selectedTab) { + MainView( + viewModel: mainViewModel, + onBackToRoleSelection: onBackToRoleSelection + ) + .tabItem { + Label(TacNetTab.main.title, systemImage: TacNetTab.main.systemImage) + } + .tag(TacNetTab.main) + + TreeView(viewModel: treeViewModel) + .tabItem { + Label(TacNetTab.treeView.title, systemImage: TacNetTab.treeView.systemImage) + } + .tag(TacNetTab.treeView) + + DataFlowView(viewModel: dataFlowViewModel) + .tabItem { + Label(TacNetTab.dataFlow.title, systemImage: TacNetTab.dataFlow.systemImage) + } + .tag(TacNetTab.dataFlow) + + SettingsView( + viewModel: settingsViewModel, + afterActionReviewViewModel: afterActionReviewViewModel, + onReleaseRole: onBackToRoleSelection + ) + .tabItem { + Label(TacNetTab.settings.title, systemImage: TacNetTab.settings.systemImage) + } + .tag(TacNetTab.settings) + } + .accessibilityIdentifier("tacnet.tab.root") + } +} + +@MainActor +final class TreeViewModel: ObservableObject { + enum NodeStatus: Equatable { + case active + case idle + case disconnected + + var color: Color { + switch self { + case .active: + return .green + case .idle: + return .orange + case .disconnected: + return .red + } + } + + var labelText: String { + switch self { + case .active: + return "Active" + case .idle: + return "Idle" + case .disconnected: + return "Disconnected" + } + } + } + + struct Row: Identifiable, Equatable { + let id: String + let label: String + let depth: Int + let claimedByText: String + let compactionDisplayText: String? + let isCompactionExpanded: Bool + } + + private struct CompactionEntry: Equatable { + let summary: String + let senderRole: String + let updatedAt: Date + } + + @Published private(set) var rows: [Row] = [] + + private let roleClaimService: RoleClaimService + private let localDeviceID: String + private let nowProvider: () -> Date + + private var lastActivityByNodeID: [String: Date] = [:] + private var compactionsByParentNodeID: [String: CompactionEntry] = [:] + private var expandedCompactionNodeIDs: Set = [] + private var cancellables: Set = [] + + init( + roleClaimService: RoleClaimService, + localDeviceID: String, + nowProvider: @escaping () -> Date = Date.init + ) { + self.roleClaimService = roleClaimService + self.localDeviceID = localDeviceID + self.nowProvider = nowProvider + + roleClaimService.$networkConfig + .receive(on: RunLoop.main) + .sink { [weak self] _ in + self?.refreshFromCurrentTree() + } + .store(in: &cancellables) + + refreshFromCurrentTree() + } + + func refreshFromCurrentTree() { + refreshRows(now: nowProvider()) + } + + func status(for nodeID: String, now: Date = Date()) -> NodeStatus { + let referenceDate = lastActivityByNodeID[nodeID] ?? now + let elapsed = max(0, now.timeIntervalSince(referenceDate)) + + if elapsed > 60 { + return .disconnected + } + if elapsed > 30 { + return .idle + } + return .active + } + + func toggleCompactionExpansion(for nodeID: String) { + if expandedCompactionNodeIDs.contains(nodeID) { + expandedCompactionNodeIDs.remove(nodeID) + } else { + expandedCompactionNodeIDs.insert(nodeID) + } + refreshRows(now: nowProvider()) + } + + func handleIncomingMessage(_ message: Message) { + guard let tree = roleClaimService.networkConfig?.tree else { + return + } + + if let senderNodeID = resolveSenderNodeID(for: message, in: tree) { + lastActivityByNodeID[senderNodeID] = message.timestamp + } + + if message.type == .compaction, + let summary = message.payload.summary?.trimmingCharacters(in: .whitespacesAndNewlines), + !summary.isEmpty, + let parentNodeID = resolvedParentNodeID(for: message, in: tree) { + compactionsByParentNodeID[parentNodeID] = CompactionEntry( + summary: summary, + senderRole: message.senderRole, + updatedAt: message.timestamp + ) + } + + refreshRows(now: nowProvider()) + } + + func handlePeerConnectionStateChanged(peerID: UUID, state: PeerConnectionState) { + guard state == .connected, + let tree = roleClaimService.networkConfig?.tree, + let nodeID = findNodeID(claimedBy: peerID.uuidString, in: tree) else { + return + } + + lastActivityByNodeID[nodeID] = nowProvider() + refreshRows(now: nowProvider()) + } + + private func refreshRows(now: Date) { + guard let tree = roleClaimService.networkConfig?.tree else { + rows = [] + lastActivityByNodeID = [:] + compactionsByParentNodeID = [:] + expandedCompactionNodeIDs = [] + return + } + + let knownNodeIDs = collectNodeIDs(in: tree) + lastActivityByNodeID = lastActivityByNodeID.filter { knownNodeIDs.contains($0.key) } + compactionsByParentNodeID = compactionsByParentNodeID.filter { knownNodeIDs.contains($0.key) } + expandedCompactionNodeIDs = expandedCompactionNodeIDs.intersection(knownNodeIDs) + + var flattenedRows: [Row] = [] + appendRows(from: tree, depth: 0, now: now, into: &flattenedRows) + rows = flattenedRows + } + + private func appendRows(from node: TreeNode, depth: Int, now: Date, into rows: inout [Row]) { + if lastActivityByNodeID[node.id] == nil { + if node.claimedBy == localDeviceID { + lastActivityByNodeID[node.id] = now + } else { + lastActivityByNodeID[node.id] = now + } + } + + let compaction = compactionsByParentNodeID[node.id] + let isExpanded = expandedCompactionNodeIDs.contains(node.id) + let displaySummary = compaction.map { entry in + if isExpanded { + return entry.summary + } + return Self.truncatedSummary(entry.summary) + } + + rows.append( + Row( + id: node.id, + label: node.label, + depth: depth, + claimedByText: "claimed_by: \(node.claimedBy?.isEmpty == false ? node.claimedBy! : "Available")", + compactionDisplayText: displaySummary, + isCompactionExpanded: isExpanded + ) + ) + + for child in node.children { + appendRows(from: child, depth: depth + 1, now: now, into: &rows) + } + } + + private func collectNodeIDs(in node: TreeNode) -> Set { + var nodeIDs: Set = [node.id] + for child in node.children { + nodeIDs.formUnion(collectNodeIDs(in: child)) + } + return nodeIDs + } + + private func resolveSenderNodeID(for message: Message, in tree: TreeNode) -> String? { + if TreeHelpers.level(of: message.senderID, in: tree) != nil { + return message.senderID + } + return findNodeID(claimedBy: message.senderID, in: tree) + } + + private func resolvedParentNodeID(for message: Message, in tree: TreeNode) -> String? { + if let parentID = message.parentID { + return parentID + } + guard let senderNodeID = resolveSenderNodeID(for: message, in: tree) else { + return nil + } + return TreeHelpers.parent(of: senderNodeID, in: tree)?.id + } + + private func findNodeID(claimedBy ownerID: String, in tree: TreeNode) -> String? { + if tree.claimedBy == ownerID { + return tree.id + } + + for child in tree.children { + if let nodeID = findNodeID(claimedBy: ownerID, in: child) { + return nodeID + } + } + return nil + } + + private static func truncatedSummary(_ summary: String, maxCharacters: Int = 84) -> String { + let trimmed = summary.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.count > maxCharacters else { + return trimmed + } + + let prefix = trimmed.prefix(max(1, maxCharacters - 1)) + return "\(prefix)…" + } +} + +struct TreeView: View { + @ObservedObject var viewModel: TreeViewModel + + var body: some View { + TimelineView(.periodic(from: .now, by: 1)) { timelineContext in + ScrollView { + LazyVStack(alignment: .leading, spacing: 10) { + if viewModel.rows.isEmpty { + Text("No tree available yet. Join or publish a network first.") + .font(.footnote) + .foregroundStyle(.secondary) + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.secondary.opacity(0.10)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } else { + ForEach(viewModel.rows) { row in + TreeNodeStatusRowView( + row: row, + status: viewModel.status(for: row.id, now: timelineContext.date) + ) { + viewModel.toggleCompactionExpansion(for: row.id) + } + } + } + } + .padding(.horizontal) + } + } + .navigationTitle("Tree View") + .onAppear { + viewModel.refreshFromCurrentTree() + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("tacnet.tree.root") + } +} + +private struct TreeNodeStatusRowView: View { + let row: TreeViewModel.Row + let status: TreeViewModel.NodeStatus + let onToggleCompaction: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .center, spacing: 8) { + Circle() + .fill(status.color) + .frame(width: 10, height: 10) + .accessibilityLabel(Text(status.labelText)) + + Text(row.label.isEmpty ? "(unnamed node)" : row.label) + .font(.subheadline.weight(.semibold)) + + Spacer(minLength: 8) + + Text(status.labelText) + .font(.caption) + .foregroundStyle(.secondary) + } + + Text(row.claimedByText) + .font(.caption) + .foregroundStyle(.secondary) + + if let compactionSummary = row.compactionDisplayText { + Button(action: onToggleCompaction) { + VStack(alignment: .leading, spacing: 4) { + Text("Compaction Summary") + .font(.caption2.weight(.bold)) + .foregroundStyle(.orange) + Text(compactionSummary) + .font(.caption) + .foregroundStyle(.primary) + .frame(maxWidth: .infinity, alignment: .leading) + Text(row.isCompactionExpanded ? "Tap to collapse" : "Tap to expand") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .buttonStyle(.plain) + } + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.secondary.opacity(0.10)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .padding(.leading, CGFloat(row.depth) * 14) + .accessibilityIdentifier("tacnet.tree.row.\(row.id)") + } +} + +@MainActor +final class DataFlowViewModel: ObservableObject { + struct IncomingEntry: Identifiable, Equatable { + var id: UUID { messageID } + let messageID: UUID + let timestamp: Date + let senderID: String + let senderRole: String + let typeLabel: String + } + + struct OutgoingEntry: Identifiable, Equatable { + var id: UUID { messageID } + let messageID: UUID + let timestamp: Date + let destinationNodeID: String + let sourceNodeIDs: [String] + let outputText: String + } + + struct ProcessingSnapshot: Equatable { + let status: CompactionEngine.ProcessingStatus + let triggerReason: CompactionEngine.TriggerReason? + let latencyMilliseconds: Double? + let inputTokenCount: Int + let outputTokenCount: Int + let compressionRatio: Double? + let sourceMessageCount: Int + + static let idle = ProcessingSnapshot( + status: .idle, + triggerReason: nil, + latencyMilliseconds: nil, + inputTokenCount: 0, + outputTokenCount: 0, + compressionRatio: nil, + sourceMessageCount: 0 + ) + + var statusLabel: String { + switch status { + case .idle: + return "Idle" + case .compacting: + return "Compacting" + } + } + + var triggerReasonLabel: String { + guard let triggerReason else { + return "—" + } + switch triggerReason { + case .timeWindow: + return "Time Window" + case .messageCount: + return "Message Count" + case .priorityKeyword: + return "Priority Keyword" + case .manual: + return "Manual" + } + } + + var latencyLabel: String { + guard let latencyMilliseconds else { + return "—" + } + return "\(Int(latencyMilliseconds.rounded())) ms" + } + + var compressionRatioLabel: String { + guard let compressionRatio else { + return "—" + } + return String(format: "%.2fx", compressionRatio) + } + } + + @Published private(set) var incomingEntries: [IncomingEntry] = [] + @Published private(set) var outgoingEntries: [OutgoingEntry] = [] + @Published private(set) var processing: ProcessingSnapshot = .idle + + func handleIncomingMessage(_ message: Message) { + incomingEntries.append( + IncomingEntry( + messageID: message.id, + timestamp: message.timestamp, + senderID: message.senderID, + senderRole: message.senderRole, + typeLabel: message.type.rawValue + ) + ) + incomingEntries.sort { lhs, rhs in + lhs.timestamp > rhs.timestamp + } + } + + func handleProcessingMetrics(_ metrics: CompactionEngine.ProcessingMetrics) { + processing = ProcessingSnapshot( + status: metrics.status, + triggerReason: metrics.triggerReason, + latencyMilliseconds: metrics.latencyMilliseconds, + inputTokenCount: metrics.inputTokenCount, + outputTokenCount: metrics.outputTokenCount, + compressionRatio: metrics.compressionRatio, + sourceMessageCount: metrics.sourceMessageCount + ) + } + + func handleOutgoingCompaction(_ emission: CompactionEngine.CompactionEmission) { + let trimmedOutput = emission.outputText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedOutput.isEmpty else { + return + } + + outgoingEntries.append( + OutgoingEntry( + messageID: emission.message.id, + timestamp: emission.generatedAt, + destinationNodeID: emission.destinationNodeID ?? "N/A", + sourceNodeIDs: emission.sourceNodeIDs, + outputText: trimmedOutput + ) + ) + outgoingEntries.sort { lhs, rhs in + lhs.timestamp > rhs.timestamp + } + } + + func resetCompactionTelemetry() { + processing = .idle + outgoingEntries = [] + } +} + +struct DataFlowView: View { + @ObservedObject var viewModel: DataFlowViewModel + + private static let timestampFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .none + formatter.timeStyle = .medium + return formatter + }() + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 14) { + incomingSection + processingSection + outgoingSection + } + .padding(.horizontal) + .padding(.top, 4) + .padding(.bottom, 12) + } + .navigationTitle("Data Flow") + .accessibilityElement(children: .contain) + .accessibilityIdentifier("tacnet.dataflow.root") + } + + private var incomingSection: some View { + GroupBox("INCOMING") { + VStack(alignment: .leading, spacing: 8) { + if viewModel.incomingEntries.isEmpty { + Text("No received messages yet.") + .font(.footnote) + .foregroundStyle(.secondary) + } else { + ForEach(viewModel.incomingEntries) { entry in + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(entry.senderRole.isEmpty ? entry.senderID : entry.senderRole) + .font(.subheadline.weight(.semibold)) + Spacer(minLength: 8) + Text(Self.timestampFormatter.string(from: entry.timestamp)) + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } + + HStack(spacing: 8) { + Text(entry.typeLabel) + .font(.caption2.weight(.bold)) + .foregroundStyle(.blue) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.blue.opacity(0.15)) + .clipShape(Capsule()) + Text(entry.senderID) + .font(.caption2.monospaced()) + .foregroundStyle(.secondary) + } + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.secondary.opacity(0.10)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + private var processingSection: some View { + GroupBox("PROCESSING") { + VStack(alignment: .leading, spacing: 8) { + processingMetricRow(title: "Gemma 4 Status", value: viewModel.processing.statusLabel) + processingMetricRow(title: "Trigger Reason", value: viewModel.processing.triggerReasonLabel) + processingMetricRow(title: "Latency", value: viewModel.processing.latencyLabel) + processingMetricRow(title: "Input Tokens", value: "\(viewModel.processing.inputTokenCount)") + processingMetricRow(title: "Output Tokens", value: "\(viewModel.processing.outputTokenCount)") + processingMetricRow(title: "Compression Ratio", value: viewModel.processing.compressionRatioLabel) + processingMetricRow(title: "Source Messages", value: "\(viewModel.processing.sourceMessageCount)") + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + private var outgoingSection: some View { + GroupBox("OUTGOING") { + VStack(alignment: .leading, spacing: 8) { + if viewModel.outgoingEntries.isEmpty { + Text("No emitted compactions yet.") + .font(.footnote) + .foregroundStyle(.secondary) + } else { + ForEach(viewModel.outgoingEntries) { entry in + VStack(alignment: .leading, spacing: 6) { + HStack { + Text("Destination: \(entry.destinationNodeID)") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + Spacer(minLength: 8) + Text(Self.timestampFormatter.string(from: entry.timestamp)) + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } + + Text("Source IDs: \(entry.sourceNodeIDs.isEmpty ? "—" : entry.sourceNodeIDs.joined(separator: ", "))") + .font(.caption2.monospaced()) + .foregroundStyle(.secondary) + + Text(entry.outputText) + .font(.body) + .foregroundStyle(.primary) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.secondary.opacity(0.10)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + @ViewBuilder + private func processingMetricRow(title: String, value: String) -> some View { + HStack(alignment: .firstTextBaseline) { + Text(title) + .font(.caption) + .foregroundStyle(.secondary) + Spacer(minLength: 10) + Text(value) + .font(.caption.monospacedDigit()) + .foregroundStyle(.primary) + } + } +} + +@MainActor +final class SettingsViewModel: ObservableObject { + struct TreeRow: Identifiable, Equatable { + let id: String + let label: String + let depth: Int + let claimedBy: String? + + var displayLabel: String { + label.isEmpty ? "(unnamed node)" : label + } + + var claimedByText: String { + if let claimedBy, !claimedBy.isEmpty { + return "claimed_by: \(claimedBy)" + } + return "claimed_by: Available" + } + + var promoteDisplayText: String { + if let claimedBy, !claimedBy.isEmpty { + return "\(displayLabel) (\(claimedBy))" + } + return displayLabel + } + } + + @Published var selectedNodeID: String? + @Published var renameDraft: String = "" + @Published var newChildLabelDraft: String = "" + @Published var promoteTargetNodeID: String? + @Published private(set) var statusMessage: String? + + private let roleClaimService: RoleClaimService + private var cancellables: Set = [] + + init(roleClaimService: RoleClaimService) { + self.roleClaimService = roleClaimService + synchronizeSelectionAndTargets() + + roleClaimService.$networkConfig + .combineLatest(roleClaimService.$activeClaimNodeID) + .receive(on: RunLoop.main) + .sink { [weak self] _, _ in + guard let self else { + return + } + self.objectWillChange.send() + self.synchronizeSelectionAndTargets() + } + .store(in: &cancellables) + } + + var showsOrganiserControls: Bool { + roleClaimService.isOrganiser + } + + var isEditTreeButtonVisible: Bool { + showsOrganiserControls + } + + var isEditTreeButtonDisabled: Bool { + !showsOrganiserControls + } + + var canReleaseRole: Bool { + roleClaimService.activeClaimNodeID != nil + } + + var claimedRoleDescription: String { + guard let activeClaimNodeID = roleClaimService.activeClaimNodeID else { + return "No active role claimed." + } + return "Claimed node: \(activeClaimNodeID)" + } + + var treeRows: [TreeRow] { + guard let tree = roleClaimService.networkConfig?.tree else { + return [] + } + + var rows: [TreeRow] = [] + appendRows(from: tree, depth: 0, into: &rows) + return rows + } + + var promotableNodeRows: [TreeRow] { + guard let config = roleClaimService.networkConfig else { + return [] + } + + return treeRows.filter { row in + guard let claimedBy = row.claimedBy, !claimedBy.isEmpty else { + return false + } + return claimedBy != config.createdBy + } + } + + var canPromoteSelectedNode: Bool { + showsOrganiserControls && promoteTargetNodeID != nil + } + + var canRenameSelectedNode: Bool { + showsOrganiserControls && + selectedNodeID != nil && + !renameDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + var canAddChildNode: Bool { + showsOrganiserControls && + !newChildLabelDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && + (selectedNodeID != nil || roleClaimService.networkConfig?.tree.id != nil) + } + + var canRemoveSelectedNode: Bool { + guard showsOrganiserControls, + let selectedNodeID, + let rootNodeID = roleClaimService.networkConfig?.tree.id else { + return false + } + return selectedNodeID != rootNodeID + } + + func selectNode(_ nodeID: String) { + selectedNodeID = nodeID + if let tree = roleClaimService.networkConfig?.tree, + let node = findNode(withID: nodeID, in: tree) { + renameDraft = node.label + } + } + + @discardableResult + func releaseRole() -> Bool { + let result = roleClaimService.releaseActiveClaim() + switch result { + case .released(let nodeID): + statusMessage = "Released \(nodeID)." + synchronizeSelectionAndTargets() + return true + case .noActiveClaim: + statusMessage = "No claimed role to release." + return false + case .unavailable: + statusMessage = "Network unavailable." + return false + case .claimed, .rejected: + statusMessage = nil + return false + } + } + + @discardableResult + func addChildToSelectedNode() -> Bool { + guard showsOrganiserControls else { + statusMessage = "Only organiser can edit the tree." + return false + } + + guard let parentID = selectedNodeID ?? roleClaimService.networkConfig?.tree.id else { + statusMessage = "No tree available to edit." + return false + } + + guard let createdNode = roleClaimService.addNode(parentID: parentID, label: newChildLabelDraft) else { + statusMessage = "Unable to add child node." + return false + } + + selectedNodeID = createdNode.id + renameDraft = createdNode.label + newChildLabelDraft = "" + statusMessage = "Added \(createdNode.label)." + synchronizeSelectionAndTargets() + return true + } + + @discardableResult + func renameSelectedNode() -> Bool { + guard showsOrganiserControls else { + statusMessage = "Only organiser can edit the tree." + return false + } + + guard let selectedNodeID else { + statusMessage = "Select a node to rename." + return false + } + + guard roleClaimService.renameNode(nodeID: selectedNodeID, newLabel: renameDraft) else { + statusMessage = "Unable to rename selected node." + return false + } + + statusMessage = "Renamed \(selectedNodeID)." + synchronizeSelectionAndTargets() + return true + } + + @discardableResult + func removeSelectedNode() -> Bool { + guard showsOrganiserControls else { + statusMessage = "Only organiser can edit the tree." + return false + } + + guard let selectedNodeID else { + statusMessage = "Select a node to remove." + return false + } + + guard roleClaimService.removeNode(nodeID: selectedNodeID) else { + statusMessage = "Unable to remove selected node." + return false + } + + statusMessage = "Removed \(selectedNodeID)." + synchronizeSelectionAndTargets() + return true + } + + @discardableResult + func handleNodeDrop(draggedNodeID: String, onto targetNodeID: String) -> Bool { + guard showsOrganiserControls else { + statusMessage = "Only organiser can edit the tree." + return false + } + + guard draggedNodeID != targetNodeID else { + statusMessage = "Drop target is unchanged." + return false + } + + guard let tree = roleClaimService.networkConfig?.tree else { + statusMessage = "No tree available to edit." + return false + } + + let sourceParentID = TreeHelpers.parent(of: draggedNodeID, in: tree)?.id + let targetParentID = TreeHelpers.parent(of: targetNodeID, in: tree)?.id + + let didApplyDrop: Bool + if let sourceParentID, sourceParentID == targetParentID { + didApplyDrop = roleClaimService.reorderNode(nodeID: draggedNodeID, beforeSiblingID: targetNodeID) + statusMessage = didApplyDrop + ? "Reordered \(draggedNodeID)." + : "Unable to reorder dragged node." + } else { + didApplyDrop = roleClaimService.moveNode(nodeID: draggedNodeID, newParentID: targetNodeID) + statusMessage = didApplyDrop + ? "Moved \(draggedNodeID) under \(targetNodeID)." + : "Unable to move dragged node." + } + + guard didApplyDrop else { + return false + } + + selectedNodeID = draggedNodeID + synchronizeSelectionAndTargets() + return true + } + + @discardableResult + func promoteSelectedNode() -> Bool { + guard showsOrganiserControls else { + statusMessage = "Only organiser can promote roles." + return false + } + + guard let targetNodeID = promoteTargetNodeID else { + statusMessage = "Select a claimed node to promote." + return false + } + + do { + try roleClaimService.validatePromoteTarget(nodeID: targetNodeID) + } catch PromoteValidationError.targetUnclaimed { + statusMessage = "Selected node is not claimed." + return false + } catch PromoteValidationError.nodeNotFound { + statusMessage = "Selected node no longer exists." + return false + } catch { + statusMessage = "Unable to validate promote target." + return false + } + + guard roleClaimService.promote(nodeID: targetNodeID) else { + statusMessage = "Unable to promote selected node." + return false + } + + statusMessage = "Promoted \(targetNodeID) to organiser." + synchronizeSelectionAndTargets() + return true + } + + private func synchronizeSelectionAndTargets() { + guard let tree = roleClaimService.networkConfig?.tree else { + selectedNodeID = nil + renameDraft = "" + promoteTargetNodeID = nil + return + } + + if let selectedNodeID, + let selectedNode = findNode(withID: selectedNodeID, in: tree) { + renameDraft = selectedNode.label + } else { + selectedNodeID = tree.id + renameDraft = tree.label + } + + let promotableIDs = Set(promotableNodeRows.map(\.id)) + if promoteTargetNodeID == nil { + promoteTargetNodeID = promotableNodeRows.first?.id + } else if let promoteTargetNodeID, !promotableIDs.contains(promoteTargetNodeID) { + self.promoteTargetNodeID = promotableNodeRows.first?.id + } + } + + private func appendRows(from node: TreeNode, depth: Int, into rows: inout [TreeRow]) { + rows.append( + TreeRow( + id: node.id, + label: node.label, + depth: depth, + claimedBy: node.claimedBy + ) + ) + + for child in node.children { + appendRows(from: child, depth: depth + 1, into: &rows) + } + } + + private func findNode(withID nodeID: String, in tree: TreeNode) -> TreeNode? { + if tree.id == nodeID { + return tree + } + + for child in tree.children { + if let node = findNode(withID: nodeID, in: child) { + return node + } + } + return nil + } +} + +@MainActor +final class AfterActionReviewViewModel: ObservableObject { + @Published var query: String = "" { + didSet { + refresh() + } + } + @Published private(set) var results: [AfterActionReviewMessage] = [] + @Published private(set) var totalMessageCount: Int = 0 + + private let store: any AfterActionReviewPersisting + + init(store: any AfterActionReviewPersisting) { + self.store = store + refresh() + } + + func record(_ message: Message) { + store.persist(message) + refresh() + } + + func refresh() { + results = store.search(query: query) + totalMessageCount = store.allMessages().count + } +} + +struct SettingsView: View { + @ObservedObject var viewModel: SettingsViewModel + @ObservedObject var afterActionReviewViewModel: AfterActionReviewViewModel + let onReleaseRole: () -> Void + + @State private var isShowingTreeEditor = false + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 14) { + roleSection + + if viewModel.isEditTreeButtonVisible { + organiserSection + } else { + GroupBox("Organiser Controls") { + Text("Only the organiser can edit tree structure or promote roles.") + .font(.footnote) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + afterActionReviewSection + + if let statusMessage = viewModel.statusMessage { + Text(statusMessage) + .font(.footnote) + .foregroundStyle(.secondary) + .multilineTextAlignment(.leading) + } + } + .padding(.horizontal) + .padding(.top, 4) + .padding(.bottom, 12) + } + .navigationTitle("Settings") + .sheet(isPresented: $isShowingTreeEditor) { + SettingsTreeEditorView(viewModel: viewModel) + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("tacnet.settings.root") + } + + private var roleSection: some View { + GroupBox("Role") { + VStack(alignment: .leading, spacing: 10) { + Text(viewModel.claimedRoleDescription) + .font(.footnote) + .foregroundStyle(.secondary) + + Button("Release Role") { + if viewModel.releaseRole() { + onReleaseRole() + } + } + .buttonStyle(.borderedProminent) + .disabled(!viewModel.canReleaseRole) + .accessibilityIdentifier("tacnet.settings.releaseRoleButton") + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + private var organiserSection: some View { + GroupBox("Organiser Controls") { + VStack(alignment: .leading, spacing: 10) { + Button("Edit Tree") { + isShowingTreeEditor = true + } + .buttonStyle(.bordered) + .disabled(viewModel.isEditTreeButtonDisabled) + .accessibilityIdentifier("tacnet.settings.editTreeButton") + + if viewModel.promotableNodeRows.isEmpty { + Text("No claimed participants available to promote.") + .font(.footnote) + .foregroundStyle(.secondary) + } else { + Picker("Promote Claimed Node", selection: $viewModel.promoteTargetNodeID) { + ForEach(viewModel.promotableNodeRows) { row in + Text(row.promoteDisplayText) + .tag(Optional(row.id)) + } + } + .pickerStyle(.menu) + + Button("Promote") { + _ = viewModel.promoteSelectedNode() + } + .buttonStyle(.borderedProminent) + .disabled(!viewModel.canPromoteSelectedNode) + .accessibilityIdentifier("tacnet.settings.promoteButton") + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + private var afterActionReviewSection: some View { + GroupBox("After-Action Review") { + VStack(alignment: .leading, spacing: 10) { + NavigationLink { + AfterActionReviewView(viewModel: afterActionReviewViewModel) + } label: { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Search Message History") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.primary) + Text("\(afterActionReviewViewModel.totalMessageCount) stored messages") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer(minLength: 8) + Image(systemName: "chevron.right") + .foregroundStyle(.secondary) + } + } + .buttonStyle(.plain) + .accessibilityIdentifier("tacnet.settings.afterActionReviewLink") + + Text("Search BROADCAST transcripts and COMPACTION summaries with sender, timestamp, type, and GPS metadata.") + .font(.footnote) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } +} + +private struct SettingsTreeEditorView: View { + @ObservedObject var viewModel: SettingsViewModel + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 14) { + GroupBox("Tree Nodes") { + VStack(alignment: .leading, spacing: 8) { + if viewModel.treeRows.isEmpty { + Text("No tree is currently available.") + .font(.footnote) + .foregroundStyle(.secondary) + } else { + ForEach(viewModel.treeRows) { row in + Button { + viewModel.selectNode(row.id) + } label: { + HStack(alignment: .top, spacing: 8) { + VStack(alignment: .leading, spacing: 4) { + Text(row.displayLabel) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.primary) + Text(row.claimedByText) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer(minLength: 8) + + if viewModel.selectedNodeID == row.id { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.blue) + } + } + .padding(.vertical, 6) + .padding(.leading, CGFloat(row.depth) * 14) + } + .buttonStyle(.plain) + .onDrag { + viewModel.selectNode(row.id) + return NSItemProvider(object: row.id as NSString) + } + .onDrop(of: [UTType.plainText], isTargeted: nil) { providers in + performDrop(providers: providers, targetNodeID: row.id) + } + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + GroupBox("Edit Selected Node") { + VStack(alignment: .leading, spacing: 10) { + TextField("Rename selected node", text: $viewModel.renameDraft) + .textFieldStyle(.roundedBorder) + + HStack(spacing: 10) { + Button("Rename") { + _ = viewModel.renameSelectedNode() + } + .buttonStyle(.bordered) + .disabled(!viewModel.canRenameSelectedNode) + + Button("Remove", role: .destructive) { + _ = viewModel.removeSelectedNode() + } + .buttonStyle(.bordered) + .disabled(!viewModel.canRemoveSelectedNode) + } + + TextField("New child label", text: $viewModel.newChildLabelDraft) + .textFieldStyle(.roundedBorder) + + Button("Add Child") { + _ = viewModel.addChildToSelectedNode() + } + .buttonStyle(.borderedProminent) + .disabled(!viewModel.canAddChildNode) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding(.horizontal) + .padding(.vertical, 12) + } + .navigationTitle("Edit Tree") + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Done") { + dismiss() + } + } + } + } + } + + private func performDrop(providers: [NSItemProvider], targetNodeID: String) -> Bool { + guard let provider = providers.first(where: { $0.canLoadObject(ofClass: NSString.self) }) else { + return false + } + + _ = provider.loadObject(ofClass: NSString.self) { object, _ in + guard let sourceNodeID = object as? NSString else { + return + } + DispatchQueue.main.async { + _ = viewModel.handleNodeDrop(draggedNodeID: sourceNodeID as String, onto: targetNodeID) + } + } + + return true + } +} + +private struct AfterActionReviewView: View { + @ObservedObject var viewModel: AfterActionReviewViewModel + + private static let timestampFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .short + formatter.timeStyle = .medium + return formatter + }() + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + TextField("Search transcripts and summaries", text: $viewModel.query) + .textFieldStyle(.roundedBorder) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + + Text(summaryText) + .font(.footnote) + .foregroundStyle(.secondary) + + if viewModel.results.isEmpty { + Text("No matching messages found.") + .font(.footnote) + .foregroundStyle(.secondary) + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.secondary.opacity(0.10)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } else { + ForEach(viewModel.results) { result in + AfterActionReviewResultRow( + result: result, + timestampFormatter: Self.timestampFormatter + ) + } + } + } + .padding(.horizontal) + .padding(.vertical, 12) + } + .navigationTitle("After-Action Review") + .onAppear { + viewModel.refresh() + } + .accessibilityIdentifier("tacnet.afterActionReview.root") + } + + private var summaryText: String { + let trimmedQuery = viewModel.query.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmedQuery.isEmpty { + return "\(viewModel.totalMessageCount) total stored messages." + } + return "\(viewModel.results.count) match(es) for “\(trimmedQuery)”." + } +} + +private struct AfterActionReviewResultRow: View { + let result: AfterActionReviewMessage + let timestampFormatter: DateFormatter + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(result.senderRole.isEmpty ? result.senderID : result.senderRole) + .font(.subheadline.weight(.semibold)) + Spacer(minLength: 8) + Text(timestampFormatter.string(from: result.timestamp)) + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } + + HStack(spacing: 8) { + Text(result.type.rawValue) + .font(.caption2.weight(.bold)) + .foregroundStyle(result.type == .broadcast ? .blue : .orange) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background( + (result.type == .broadcast ? Color.blue : Color.orange) + .opacity(0.18) + ) + .clipShape(Capsule()) + Text(result.senderID) + .font(.caption2.monospaced()) + .foregroundStyle(.secondary) + } + + Text(result.body.isEmpty ? "(no message body)" : result.body) + .font(.body) + .foregroundStyle(.primary) + .frame(maxWidth: .infinity, alignment: .leading) + + Text(locationText) + .font(.caption2.monospaced()) + .foregroundStyle(.secondary) + } + .padding(12) + .background(Color.secondary.opacity(0.10)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + + private var locationText: String { + if result.isFallbackLocation { + return "GPS unavailable" + } + return String( + format: "GPS %.5f, %.5f (±%.1fm)", + result.latitude, + result.longitude, + result.accuracy + ) + } +} + +/// Tracks press-in / press-out transitions from a SwiftUI `ButtonStyle.Configuration.isPressed` +/// stream and dispatches exactly one `onPressBegan` per press and exactly one `onPressEnded` +/// per release. De-duplicates redundant `isPressed` deliveries (SwiftUI occasionally re-renders +/// the style's body with the same pressed value) so the push-to-talk handler pair fires once +/// per physical press. +/// +/// This type is exposed (internal) so the behavior can be unit-tested without instantiating +/// SwiftUI's private gesture recognizer stack. +@MainActor +final class PTTPressDispatcher { + private var isCurrentlyPressed = false + private let onPressBegan: () -> Void + private let onPressEnded: () -> Void + + init( + onPressBegan: @escaping () -> Void, + onPressEnded: @escaping () -> Void + ) { + self.onPressBegan = onPressBegan + self.onPressEnded = onPressEnded + } + + /// Update the tracked press state. Call this whenever SwiftUI emits a new + /// `configuration.isPressed` value. The first transition to `true` fires `onPressBegan` + /// once; the first subsequent transition to `false` fires `onPressEnded` once. + func updatePressState(isPressed: Bool) { + if isPressed && !isCurrentlyPressed { + isCurrentlyPressed = true + onPressBegan() + } else if !isPressed && isCurrentlyPressed { + isCurrentlyPressed = false + onPressEnded() + } + } + + /// Exposes the current press state for tests. + var isPressed: Bool { + isCurrentlyPressed + } +} + +/// Canonical SwiftUI push-to-talk button style. Uses a real `Button` under the hood so the +/// gesture recognizer participates correctly in SwiftUI's built-in gesture arbitration — +/// unlike a bare `DragGesture(minimumDistance: 0)` attached via `.gesture()`, which loses +/// arbitration to ancestor `TabView` horizontal-swipe and `NavigationStack` back-edge +/// gestures and triggers the iOS `Gesture: System gesture gate timed out.` log. +/// +/// The style observes `configuration.isPressed` and forwards begin/end edges to a +/// `PTTPressDispatcher`, which then invokes the caller-provided handlers exactly once per +/// physical press/release. Logs every dispatch with the `[PTT]` prefix to aid on-device +/// debugging. +struct PTTButtonStyle: ButtonStyle { + let tintColor: Color + let isVisuallyDimmed: Bool + let onPressBegan: () -> Void + let onPressEnded: () -> Void + + func makeBody(configuration: Configuration) -> some View { + PTTButtonStyleBody( + configuration: configuration, + tintColor: tintColor, + isVisuallyDimmed: isVisuallyDimmed, + onPressBegan: onPressBegan, + onPressEnded: onPressEnded + ) + } + + private struct PTTButtonStyleBody: View { + let configuration: Configuration + let tintColor: Color + let isVisuallyDimmed: Bool + let onPressBegan: () -> Void + let onPressEnded: () -> Void + + @State private var dispatcher: PTTPressDispatcher? + + var body: some View { + configuration.label + .opacity(isVisuallyDimmed ? 0.55 : 1.0) + .scaleEffect(configuration.isPressed ? 0.97 : 1.0) + .animation(.easeOut(duration: 0.1), value: configuration.isPressed) + .onAppear { + if dispatcher == nil { + dispatcher = PTTPressDispatcher( + onPressBegan: { + pttLog("[PTT] Button press-began (gesture dispatched)") + onPressBegan() + }, + onPressEnded: { + pttLog("[PTT] Button press-ended (gesture released)") + onPressEnded() + } + ) + } + } + .onChange(of: configuration.isPressed) { _, nowPressed in + dispatcher?.updatePressState(isPressed: nowPressed) + } + } + } +} + +/// Circular push-to-talk control. Renders the visual state (title, symbol, tint) from the +/// caller and wires press/release to `onPressBegan` / `onPressEnded`. Adds the +/// `tacnet.main.pttButton` accessibility identifier so XCUITest can locate the real button +/// and drive it with `press(forDuration:)`. +struct PTTButton: View { + let title: String + let symbol: String + let color: Color + /// When `true` the button ignores interaction entirely (used while the view model is in + /// `.sending` state). Disconnected-from-mesh state is NOT modelled here — the view model + /// rejects the press itself so that the gated-path `[PTT]` log fires and the user + /// receives immediate feedback. + let isInteractionDisabled: Bool + let isVisuallyDimmed: Bool + let onPressBegan: () -> Void + let onPressEnded: () -> Void + + var body: some View { + Button(action: { /* intentionally empty; long-press handled by ButtonStyle */ }) { + ZStack { + Circle() + .fill(color.opacity(0.20)) + .overlay( + Circle() + .stroke(color, lineWidth: 3) + ) + + VStack(spacing: 8) { + Image(systemName: symbol) + .font(.system(size: 40, weight: .semibold)) + .foregroundStyle(color) + Text(title) + .font(.headline) + .foregroundStyle(.primary) + .multilineTextAlignment(.center) + .padding(.horizontal, 12) + } + } + .frame(width: 220, height: 220) + .contentShape(Circle()) + } + .buttonStyle( + PTTButtonStyle( + tintColor: color, + isVisuallyDimmed: isVisuallyDimmed, + onPressBegan: onPressBegan, + onPressEnded: onPressEnded + ) + ) + .disabled(isInteractionDisabled) + .accessibilityLabel(Text(title)) + .accessibilityIdentifier("tacnet.main.pttButton") + } +} + +private struct MainView: View { + @ObservedObject var viewModel: MainViewModel + @ObservedObject private var logCapture = UITestLogCapture.shared + let onBackToRoleSelection: () -> Void + + var body: some View { + VStack(spacing: 14) { + if UITestMode.captureLogs { + pttDebugLogOverlay + } + if let disconnectionMessage = viewModel.disconnectionMessage { + Label(disconnectionMessage, systemImage: "wifi.exclamationmark") + .font(.footnote) + .foregroundStyle(.red) + .multilineTextAlignment(.center) + .padding(.horizontal) + } + + if let errorMessage = viewModel.errorMessage, errorMessage != viewModel.disconnectionMessage { + Text(errorMessage) + .font(.footnote) + .foregroundStyle(.red) + .multilineTextAlignment(.center) + .padding(.horizontal) + } + + ScrollView { + LazyVStack(alignment: .leading, spacing: 10) { + if viewModel.feedEntries.isEmpty { + Text("No live entries yet. Incoming sibling broadcasts and compaction summaries will appear here.") + .font(.footnote) + .foregroundStyle(.secondary) + .multilineTextAlignment(.leading) + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.secondary.opacity(0.10)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } else { + ForEach(viewModel.feedEntries) { entry in + LiveFeedEntryRow(entry: entry) + } + } + } + .padding(.horizontal) + } + + pttControl + .padding(.bottom, 12) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("tacnet.main.pttControl") + } + .navigationTitle("Main Feed") + .accessibilityElement(children: .contain) + .accessibilityIdentifier("tacnet.main.root") + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Roles", action: onBackToRoleSelection) + .accessibilityIdentifier("tacnet.main.rolesButton") + } + } + .onAppear { + viewModel.refreshConnectionState() + } + } + + private var pttControl: some View { + PTTButton( + title: viewModel.pttButtonTitle, + symbol: viewModel.pttButtonSymbol, + color: viewModel.pttButtonColor, + // Only block interaction while transcribing/sending — disconnected presses must + // still dispatch so the gated-rejection `[PTT]` log surfaces user feedback. + isInteractionDisabled: viewModel.pttState == .sending, + isVisuallyDimmed: viewModel.isPTTDisabled, + onPressBegan: { + Task { @MainActor in + await viewModel.startPushToTalk() + } + }, + onPressEnded: { + Task { @MainActor in + await viewModel.stopPushToTalk() + } + } + ) + } + + /// Hidden debug surface used ONLY when the app is launched with + /// `--ui-test-capture-logs` to support VAL-PTT-002 / VAL-PTT-003 on the real + /// Main tab. Exposes the captured `[PTT]` log buffer and a refresh button (which + /// forces `UITestLogCapture.shared` to pull fresh entries from `OSLogStore`). + /// The identifiers `tacnet.debug.logBuffer` and `tacnet.debug.refreshLogBuffer` + /// are read by XCUITest; the overlay is intentionally shown (not zero-framed) + /// so XCUITest can hit the refresh button reliably. + private var pttDebugLogOverlay: some View { + VStack(alignment: .leading, spacing: 4) { + Text("PTT Log Buffer (UI-test capture)") + .font(.caption2.weight(.semibold)) + .foregroundStyle(.secondary) + ScrollView { + Text(logCapture.snapshotText) + .font(.caption2.monospaced()) + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + .accessibilityIdentifier("tacnet.debug.logBuffer") + } + .frame(height: 72) + .background(Color.secondary.opacity(0.08)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + + Button { + logCapture.scanSystemLog() + } label: { + Text("Refresh PTT Log") + .font(.caption2) + } + .buttonStyle(.bordered) + .accessibilityIdentifier("tacnet.debug.refreshLogBuffer") + } + .padding(.horizontal) + } +} + +private struct LiveFeedEntryRow: View { + let entry: MainViewModel.FeedEntry + + private static let timestampFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .none + formatter.timeStyle = .medium + return formatter + }() + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(entry.senderRole) + .font(.subheadline.weight(.semibold)) + Spacer(minLength: 8) + Text(Self.timestampFormatter.string(from: entry.timestamp)) + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } + + HStack { + Text(entry.type.badgeTitle) + .font(.caption2.weight(.bold)) + .foregroundStyle(entry.type.badgeForegroundColor) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(entry.type.badgeBackgroundColor) + .clipShape(Capsule()) + Spacer(minLength: 8) + } + + Text(entry.text) + .font(.body) + .foregroundStyle(.primary) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(12) + .background(Color.secondary.opacity(0.10)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } +} + +@MainActor +final class MainViewModel: ObservableObject { + enum PTTState: Equatable { + case idle + case recording + case sending + } + + enum FeedEntryType: String, Equatable { + case broadcast = "BROADCAST" + case compaction = "COMPACTION" + + var badgeTitle: String { + rawValue + } + + var badgeForegroundColor: Color { + switch self { + case .broadcast: + return .blue + case .compaction: + return .orange + } + } + + var badgeBackgroundColor: Color { + switch self { + case .broadcast: + return Color.blue.opacity(0.18) + case .compaction: + return Color.orange.opacity(0.18) + } + } + } + + struct FeedEntry: Identifiable, Equatable { + let id: UUID + let senderRole: String + let timestamp: Date + let type: FeedEntryType + let text: String + } + + static let disconnectedErrorText = "Disconnected from mesh. Reconnect to use push-to-talk." + + @Published private(set) var feedEntries: [FeedEntry] = [] + @Published private(set) var pttState: PTTState = .idle + @Published private(set) var isConnected: Bool + @Published private(set) var errorMessage: String? + + private let meshService: BluetoothMeshService + private let roleClaimService: RoleClaimService + private let localDeviceID: String + private let messageRouter: MessageRouter + private let audioService: AudioService + private let ttsService: (any TextToSpeechService)? + private var seenMessageIDs: Set = [] + var onBroadcastPublished: ((Message) -> Void)? + + init( + meshService: BluetoothMeshService, + roleClaimService: RoleClaimService, + localDeviceID: String, + messageRouter: MessageRouter = MessageRouter(), + audioService: AudioService = AudioService(), + ttsService: (any TextToSpeechService)? = nil + ) { + self.meshService = meshService + self.roleClaimService = roleClaimService + self.localDeviceID = localDeviceID + self.messageRouter = messageRouter + self.audioService = audioService + self.ttsService = ttsService + isConnected = !meshService.connectedPeerIDs.isEmpty + if !isConnected { + errorMessage = Self.disconnectedErrorText + } + } + + var isPTTDisabled: Bool { + pttState == .sending || !isConnected + } + + var disconnectionMessage: String? { + isConnected ? nil : Self.disconnectedErrorText + } + + var pttButtonTitle: String { + switch pttState { + case .idle: + return "Hold to Talk" + case .recording: + return "Recording…\nRelease to Send" + case .sending: + return "Sending…" + } + } + + var pttButtonSymbol: String { + switch pttState { + case .idle: + return "mic.fill" + case .recording: + return "record.circle.fill" + case .sending: + return "paperplane.fill" + } + } + + var pttButtonColor: Color { + switch pttState { + case .idle: + return .blue + case .recording: + return .red + case .sending: + return .orange + } + } + + func refreshConnectionState() { + let hasConnectedPeers = !meshService.connectedPeerIDs.isEmpty + isConnected = hasConnectedPeers + if hasConnectedPeers { + if errorMessage == Self.disconnectedErrorText { + errorMessage = nil + } + } else if pttState != .recording { + errorMessage = Self.disconnectedErrorText + } + } + + func handlePeerConnectionStateChanged(peerID _: UUID, state _: PeerConnectionState) { + refreshConnectionState() + } + + func handleIncomingMessage(_ message: Message) { + NSLog("[MSG] Received %@ from '%@'", message.type.rawValue, message.senderRole) + guard !seenMessageIDs.contains(message.id), + let context = localContext() else { + return + } + + let entryType: FeedEntryType + let textValue: String + + switch message.type { + case .broadcast: + guard shouldDisplaySiblingBroadcast(message, localNodeID: context.localNodeID, tree: context.config.tree), + let transcript = message.payload.transcript?.trimmingCharacters(in: .whitespacesAndNewlines), + !transcript.isEmpty else { + return + } + entryType = .broadcast + textValue = transcript + + case .compaction: + guard messageRouter.shouldDisplay(message, for: context.localNodeID, in: context.config.tree), + let summary = message.payload.summary?.trimmingCharacters(in: .whitespacesAndNewlines), + !summary.isEmpty else { + return + } + entryType = .compaction + textValue = summary + + default: + return + } + + seenMessageIDs.insert(message.id) + feedEntries.append( + FeedEntry( + id: message.id, + senderRole: message.senderRole, + timestamp: message.timestamp, + type: entryType, + text: textValue + ) + ) + feedEntries.sort { lhs, rhs in + lhs.timestamp > rhs.timestamp + } + + if let ttsService { + let role = message.senderRole + Task { await ttsService.speak(textValue, senderRole: role) } + } + } + + func startPushToTalk() async { + guard pttState == .idle else { + return + } + + if let ttsService { + Task { await ttsService.stopSpeaking() } + } + + guard !meshService.connectedPeerIDs.isEmpty else { + pttLog("[PTT] ❌ Rejected — disconnected from mesh (0 connected peers)") + pttState = .idle + errorMessage = Self.disconnectedErrorText + isConnected = false + return + } + + guard localContext() != nil else { + pttLog("[PTT] ❌ No role claimed — cannot transmit") + errorMessage = "Claim a role before transmitting." + return + } + + pttLog("[PTT] Recording started (connected peers: \(meshService.connectedPeerIDs.count))") + do { + try await audioService.pttPressed() + pttState = .recording + errorMessage = nil + } catch { + pttState = .idle + pttLog("[PTT] ❌ Failed to start recording: \(error.localizedDescription)") + errorMessage = "Unable to start recording: \(error.localizedDescription)" + } + } + + func stopPushToTalk() async { + guard pttState == .recording else { + return + } + + pttLog("[PTT] Recording stopped — transcribing…") + pttState = .sending + + do { + let queuedSequence = try await audioService.pttReleased() + guard let queuedSequence else { + pttLog("[PTT] Silence detected — no speech found, discarding clip") + pttState = .idle + return + } + + pttLog("[PTT] Waiting for transcription (model may be loading on first use)…") + await audioService.waitForIdle() + let history = await audioService.transcriptHistory + guard let transcript = history.first(where: { $0.sequence == queuedSequence })?.transcript, + !transcript.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + pttLog("[PTT] Transcription produced empty result — discarding") + pttState = .idle + return + } + + pttLog("[PTT] ✅ Transcript: \"\(transcript)\"") + publishLocalTranscript(transcript) + pttState = .idle + } catch { + pttState = .idle + pttLog("[PTT] ❌ Error: \(error.localizedDescription)") + errorMessage = "Unable to send message: \(error.localizedDescription)" + } + } + + private func publishLocalTranscript(_ transcript: String) { + guard let context = localContext() else { + errorMessage = "Claim a role before transmitting." + return + } + + let outboundMessage = messageRouter.makeBroadcastMessage( + transcript: transcript, + senderID: localDeviceID, + senderNodeID: context.localNodeID, + senderRole: context.senderRole, + in: context.config.tree + ) + let peers = meshService.connectedPeerIDs.count + pttLog("[PTT] Publishing broadcast from role '\(context.senderRole)' → \(peers) connected peer(s)") + if peers == 0 { + pttLog("[PTT] No peers connected — message queued in relay, will send when peer joins") + } + meshService.publish(outboundMessage) + onBroadcastPublished?(outboundMessage) + } + + private struct LocalContext { + let config: NetworkConfig + let localNodeID: String + let senderRole: String + } + + private func localContext() -> LocalContext? { + guard let config = roleClaimService.networkConfig else { + return nil + } + + let localNodeID = roleClaimService.activeClaimNodeID + ?? findNodeID(claimedBy: localDeviceID, in: config.tree) + guard let localNodeID else { + return nil + } + + let senderRole = findNode(withID: localNodeID, in: config.tree)?.label ?? "participant" + return LocalContext(config: config, localNodeID: localNodeID, senderRole: senderRole) + } + + private func shouldDisplaySiblingBroadcast(_ message: Message, localNodeID: String, tree: TreeNode) -> Bool { + guard let senderNodeID = resolveSenderNodeID(for: message, in: tree), + senderNodeID != localNodeID, + let localParentID = TreeHelpers.parent(of: localNodeID, in: tree)?.id, + let senderParentID = TreeHelpers.parent(of: senderNodeID, in: tree)?.id else { + return false + } + + return localParentID == senderParentID + } + + private func resolveSenderNodeID(for message: Message, in tree: TreeNode) -> String? { + if TreeHelpers.level(of: message.senderID, in: tree) != nil { + return message.senderID + } + return findNodeID(claimedBy: message.senderID, in: tree) + } + + private func findNodeID(claimedBy deviceID: String, in tree: TreeNode) -> String? { + if tree.claimedBy == deviceID { + return tree.id + } + + for child in tree.children { + if let nodeID = findNodeID(claimedBy: deviceID, in: child) { + return nodeID + } + } + return nil + } + + private func findNode(withID nodeID: String, in tree: TreeNode) -> TreeNode? { + if tree.id == nodeID { + return tree + } + + for child in tree.children { + if let node = findNode(withID: nodeID, in: child) { + return node + } + } + return nil + } +} + +@MainActor +final class AppNetworkCoordinator: ObservableObject { + typealias CompactionEngineFactory = ( + _ localDeviceID: String, + _ localNodeID: String, + _ localSenderRole: String, + _ initialTree: TreeNode, + _ messageRouter: MessageRouter + ) -> CompactionEngine + + let localDeviceID: String + let meshService: BluetoothMeshService + let discoveryService: NetworkDiscoveryService + let treeSyncService: TreeSyncService + let roleClaimService: RoleClaimService + let mainViewModel: MainViewModel + let treeViewModel: TreeViewModel + let dataFlowViewModel: DataFlowViewModel + let settingsViewModel: SettingsViewModel + let afterActionReviewViewModel: AfterActionReviewViewModel + + private let messageRouter: MessageRouter + private let compactionEngineFactory: CompactionEngineFactory + private var compactionEngine: CompactionEngine? + private var compactionEngineNodeID: String? + private var cancellables: Set = [] + + init( + meshService: BluetoothMeshService = BluetoothMeshService(), + localDeviceID: String = ProcessInfo.processInfo.hostName, + messageRouter: MessageRouter = MessageRouter(), + mainAudioService: AudioService = AudioService(), + ttsService: (any TextToSpeechService)? = AVSpeechTextToSpeechService(), + compactionEngineFactory: @escaping CompactionEngineFactory = { localDeviceID, localNodeID, localSenderRole, initialTree, messageRouter in + CompactionEngine( + localDeviceID: localDeviceID, + localNodeID: localNodeID, + localSenderRole: localSenderRole, + initialTree: initialTree, + messageRouter: messageRouter + ) + } + ) { + self.localDeviceID = localDeviceID + self.meshService = meshService + self.messageRouter = messageRouter + self.compactionEngineFactory = compactionEngineFactory + + let reviewStore: any AfterActionReviewPersisting + if #available(iOS 17.0, *) { + reviewStore = (try? SwiftDataAfterActionReviewStore()) ?? InMemoryAfterActionReviewStore() + } else { + reviewStore = InMemoryAfterActionReviewStore() + } + afterActionReviewViewModel = AfterActionReviewViewModel(store: reviewStore) + + discoveryService = NetworkDiscoveryService(meshService: meshService) + treeSyncService = TreeSyncService( + meshService: meshService, + configStore: NetworkConfigStore(storageKey: "TacNet.NetworkConfig.Live") + ) + roleClaimService = RoleClaimService( + meshService: meshService, + treeSyncService: treeSyncService, + localDeviceID: localDeviceID + ) + mainViewModel = MainViewModel( + meshService: meshService, + roleClaimService: roleClaimService, + localDeviceID: localDeviceID, + messageRouter: messageRouter, + audioService: mainAudioService, + ttsService: ttsService + ) + treeViewModel = TreeViewModel( + roleClaimService: roleClaimService, + localDeviceID: localDeviceID + ) + dataFlowViewModel = DataFlowViewModel() + settingsViewModel = SettingsViewModel(roleClaimService: roleClaimService) + mainViewModel.onBroadcastPublished = { [weak self] message in + self?.afterActionReviewViewModel.record(message) + } + + roleClaimService.$networkConfig + .combineLatest(roleClaimService.$activeClaimNodeID) + .receive(on: RunLoop.main) + .sink { [weak self] config, activeClaimNodeID in + self?.configureCompactionEngine( + networkConfig: config, + activeClaimNodeID: activeClaimNodeID + ) + } + .store(in: &cancellables) + + configureCompactionEngine( + networkConfig: roleClaimService.networkConfig, + activeClaimNodeID: roleClaimService.activeClaimNodeID + ) + + meshService.onMessageReceived = { [weak self] message in + Task { @MainActor in + self?.afterActionReviewViewModel.record(message) + self?.roleClaimService.handleIncomingMessage(message) + self?.mainViewModel.handleIncomingMessage(message) + self?.treeViewModel.handleIncomingMessage(message) + self?.dataFlowViewModel.handleIncomingMessage(message) + self?.handleCompactionPipeline(for: message) + } + } + + meshService.onPeerConnectionStateChanged = { [weak self] peerID, state in + Task { @MainActor in + self?.treeSyncService.handlePeerStateChange(peerID: peerID, state: state) + self?.roleClaimService.handlePeerStateChange(peerID: peerID, state: state) + self?.mainViewModel.handlePeerConnectionStateChanged(peerID: peerID, state: state) + self?.treeViewModel.handlePeerConnectionStateChanged(peerID: peerID, state: state) + } + } + + // UI-test seeding: must come after both callbacks are wired so the MainViewModel + // observes the happy-path (connected) state. Production launches set + // `UITestMode.meshPeerCount == 0` and this is a no-op. + let seedCount = UITestMode.meshPeerCount + if seedCount > 0 { + meshService.seedUITestConnectedPeers(count: seedCount) + mainViewModel.refreshConnectionState() + } + } + + func publish(networkConfig: NetworkConfig) { + let securedConfig = treeSyncService.secureConfigForPublishing(networkConfig) + treeSyncService.setLocalConfig(securedConfig) + meshService.publishNetwork(securedConfig) + } + + func activateRoleClaiming(with config: NetworkConfig) { + if treeSyncService.localConfig?.networkID != config.networkID { + treeSyncService.setLocalConfig(config) + } + meshService.start() + } + + func handleScenePhase(_ phase: ScenePhase) { + switch phase { + case .active: + meshService.start() + mainViewModel.refreshConnectionState() + case .inactive, .background: + guard let compactionEngine else { + return + } + Task { + await compactionEngine.flushQueuedChildTranscripts() + await compactionEngine.flushQueuedL1Compactions() + } + @unknown default: + break + } + } + + private func configureCompactionEngine( + networkConfig: NetworkConfig?, + activeClaimNodeID: String? + ) { + guard let networkConfig else { + compactionEngine = nil + compactionEngineNodeID = nil + dataFlowViewModel.resetCompactionTelemetry() + return + } + + let resolvedLocalNodeID = activeClaimNodeID + ?? findNodeID(claimedBy: localDeviceID, in: networkConfig.tree) + + guard let resolvedLocalNodeID, + let localNode = findNode(withID: resolvedLocalNodeID, in: networkConfig.tree) else { + compactionEngine = nil + compactionEngineNodeID = nil + dataFlowViewModel.resetCompactionTelemetry() + return + } + + if compactionEngineNodeID == resolvedLocalNodeID, + let compactionEngine { + Task { + await compactionEngine.updateTree(networkConfig.tree) + } + return + } + + let engine = compactionEngineFactory( + localDeviceID, + resolvedLocalNodeID, + localNode.label, + networkConfig.tree, + messageRouter + ) + compactionEngine = engine + compactionEngineNodeID = resolvedLocalNodeID + dataFlowViewModel.resetCompactionTelemetry() + + Task { + await engine.setProcessingObserver { [weak self] metrics in + Task { @MainActor in + self?.dataFlowViewModel.handleProcessingMetrics(metrics) + } + } + + await engine.setCompactionEmissionObserver { [weak self] emission in + Task { @MainActor in + guard let self else { + return + } + self.dataFlowViewModel.handleOutgoingCompaction(emission) + self.afterActionReviewViewModel.record(emission.message) + self.meshService.publish(emission.message) + } + } + } + } + + private func handleCompactionPipeline(for message: Message) { + guard let compactionEngine, + let tree = roleClaimService.networkConfig?.tree, + let senderNodeID = resolveSenderNodeID(for: message, in: tree) else { + return + } + + switch message.type { + case .broadcast: + guard let transcript = message.payload.transcript?.trimmingCharacters(in: .whitespacesAndNewlines), + !transcript.isEmpty else { + return + } + Task { + await compactionEngine.enqueueChildTranscript(transcript, from: senderNodeID) + } + + case .compaction: + guard let summary = message.payload.summary?.trimmingCharacters(in: .whitespacesAndNewlines), + !summary.isEmpty else { + return + } + Task { + await compactionEngine.enqueueL1CompactionSummary(summary, from: senderNodeID) + } + + default: + break + } + } + + private func resolveSenderNodeID(for message: Message, in tree: TreeNode) -> String? { + if TreeHelpers.level(of: message.senderID, in: tree) != nil { + return message.senderID + } + return findNodeID(claimedBy: message.senderID, in: tree) + } + + private func findNodeID(claimedBy ownerID: String, in tree: TreeNode) -> String? { + if tree.claimedBy == ownerID { + return tree.id + } + + for child in tree.children { + if let nodeID = findNodeID(claimedBy: ownerID, in: child) { + return nodeID + } + } + return nil + } + + private func findNode(withID nodeID: String, in tree: TreeNode) -> TreeNode? { + if tree.id == nodeID { + return tree + } + + for child in tree.children { + if let node = findNode(withID: nodeID, in: child) { + return node + } + } + return nil + } +} + +@MainActor +final class AppBootstrapViewModel: ObservableObject { + @Published private(set) var downloadProgress: Double = 0 + @Published private(set) var downloadedBytes: Int64 = 0 + @Published private(set) var isDownloadComplete = false + @Published private(set) var errorMessage: String? + + private let downloadService: ModelDownloadService + private let expectedModelSizeBytes: Int64 + let modelName: String + private var hasStarted = false + private static let byteFormatter: ByteCountFormatter = { + let formatter = ByteCountFormatter() + formatter.countStyle = .file + formatter.allowedUnits = [.useKB, .useMB, .useGB] + formatter.includesUnit = true + formatter.isAdaptive = true + return formatter + }() + + init( + downloadService: ModelDownloadService = .live, + modelName: String = "Gemma 4 E4B INT4", + expectedModelSizeBytes: Int64 = ModelDownloadConfiguration.live.expectedModelSizeBytes + ) { + self.downloadService = downloadService + self.modelName = modelName + self.expectedModelSizeBytes = expectedModelSizeBytes + } + + var progressLabel: String { + "\(Int((downloadProgress * 100).rounded()))%" + } + + var byteProgressLabel: String { + "\(formatBytes(downloadedBytes)) / \(formatBytes(expectedModelSizeBytes))" + } + + func startIfNeeded() { + guard !hasStarted else { return } + hasStarted = true + + if UITestMode.skipDownload { + NSLog("[ModelDownload] UI test mode — bypassing download gate") + downloadProgress = 1 + downloadedBytes = expectedModelSizeBytes + isDownloadComplete = true + errorMessage = nil + return + } + + if let fixture = UITestMode.downloadFixture { + switch fixture { + case "failfast": + NSLog("[ModelDownload] UI test fixture 'failfast' — unlocking gate immediately") + downloadProgress = 1 + downloadedBytes = expectedModelSizeBytes + isDownloadComplete = true + errorMessage = nil + return + case "stuck": + NSLog("[ModelDownload] UI test fixture 'stuck' — holding gate at 0%% with no error") + downloadProgress = 0 + downloadedBytes = 0 + isDownloadComplete = false + errorMessage = nil + return + default: + NSLog("[ModelDownload] Unknown UI test fixture '%@' — falling through to real download", fixture) + } + } + + Task { + if await downloadService.canUseTacticalFeatures() { + downloadProgress = 1 + downloadedBytes = expectedModelSizeBytes + isDownloadComplete = true + errorMessage = nil + return + } + + do { + _ = try await downloadService.ensureModelAvailable { [weak self] progress in + Task { @MainActor in + guard let self else { return } + self.setProgress(progress) + } + } + + downloadProgress = 1 + downloadedBytes = expectedModelSizeBytes + isDownloadComplete = true + errorMessage = nil + } catch { + errorMessage = message(for: error) + } + } + } + + func retry() { + hasStarted = false + errorMessage = nil + startIfNeeded() + } + + private func setProgress(_ progress: Double) { + let clampedProgress = min(max(progress, 0), 1) + downloadProgress = max(downloadProgress, clampedProgress) + + let bytesFromProgress = Int64((clampedProgress * Double(expectedModelSizeBytes)).rounded()) + downloadedBytes = max(downloadedBytes, min(bytesFromProgress, expectedModelSizeBytes)) + } + + private func formatBytes(_ bytes: Int64) -> String { + Self.byteFormatter.string(fromByteCount: max(bytes, 0)) + } + + private func message(for error: Error) -> String { + guard let downloadError = error as? ModelDownloadServiceError else { + return error.localizedDescription + } + + switch downloadError { + case let .insufficientStorage(requiredBytes, availableBytes): + let required = formatBytes(requiredBytes) + let available = formatBytes(availableBytes) + return "Insufficient storage for model download. Required: \(required). Available: \(available). Free up space and retry." + case let .interrupted(canResume): + if canResume { + return "Download interrupted. Retry to resume from where it left off." + } + return "Download interrupted. Retry to restart the model download." + case let .network(underlyingDescription): + return "Model download failed: \(underlyingDescription)" + case .invalidArchive: + return "Model download failed: server returned a non-archive payload. The model URL may be inaccessible or require authentication." + } + } +} + +#Preview { + ContentView() +} diff --git a/TacNetTests/Screenshots/01-download-gate.png b/TacNetTests/Screenshots/01-download-gate.png new file mode 100644 index 00000000..efa800a3 Binary files /dev/null and b/TacNetTests/Screenshots/01-download-gate.png differ diff --git a/TacNetTests/Screenshots/02-welcome.png b/TacNetTests/Screenshots/02-welcome.png new file mode 100644 index 00000000..b8110a56 Binary files /dev/null and b/TacNetTests/Screenshots/02-welcome.png differ diff --git a/TacNetTests/Screenshots/03-pin-entry.png b/TacNetTests/Screenshots/03-pin-entry.png new file mode 100644 index 00000000..539526de Binary files /dev/null and b/TacNetTests/Screenshots/03-pin-entry.png differ diff --git a/TacNetTests/TacNetTests.swift b/TacNetTests/TacNetTests.swift new file mode 100644 index 00000000..8f43658d --- /dev/null +++ b/TacNetTests/TacNetTests.swift @@ -0,0 +1,4832 @@ +import XCTest +import SwiftUI +@testable import TacNet + +final class TacNetTests: XCTestCase { + func testCactusFunctionsAreCallableViaSwiftBindings() { + XCTAssertTrue(CactusFunctionProbe.verifyCallableSymbols()) + } + + func testFrameworkImportsProbeCompiles() { + FrameworkImportsProbe.touchFrameworkSymbols() + XCTAssertTrue(true) + } + + func testTreeNodeRoundTripEncodingWithNestedChildren() throws { + let original = TreeNode( + id: "root", + label: "Root", + claimedBy: "commander", + children: [ + TreeNode( + id: "alpha", + label: "Alpha", + claimedBy: nil, + children: [ + TreeNode( + id: "alpha-1", + label: "Alpha 1", + claimedBy: "device-a1", + children: [] + ) + ] + ), + TreeNode( + id: "bravo", + label: "Bravo", + claimedBy: "device-b", + children: [] + ) + ] + ) + + let encoded = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(TreeNode.self, from: encoded) + + XCTAssertEqual(decoded, original) + } + + func testTreeNodeDecodingRejectsMalformedJSON() { + let malformedPayloads = [ + #"{"label":"Root","claimed_by":null,"children":[]}"#, // missing id + #"{"id":"root","label":"Root","claimed_by":null,"children":"invalid"}"#, // children wrong type + #"{}"#, // empty object + #"[]"# // array instead of object + ] + + for json in malformedPayloads { + let data = Data(json.utf8) + XCTAssertThrowsError(try JSONDecoder().decode(TreeNode.self, from: data)) { error in + XCTAssertTrue(error is DecodingError, "Expected DecodingError, got \(type(of: error))") + } + } + } + + func testNetworkConfigVersionMonotonicityAndStaleDiscard() { + let networkID = UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE")! + var local = NetworkConfig( + networkName: "TacNet Alpha", + networkID: networkID, + createdBy: "organizer-device", + pinHash: "pinhash", + version: 1, + tree: TreeNode(id: "root", label: "Root", claimedBy: nil, children: []) + ) + + local.applyMutation { tree in + tree.label = "Root Updated" + } + XCTAssertEqual(local.version, 2, "Tree mutation must increment version exactly by 1") + + let stale = NetworkConfig( + networkName: "TacNet Alpha", + networkID: networkID, + createdBy: "organizer-device", + pinHash: "pinhash", + version: 2, + tree: TreeNode(id: "root", label: "STALE", claimedBy: nil, children: []) + ) + XCTAssertFalse(local.mergeIfNewer(stale), "Stale versions (<= local) must be discarded") + XCTAssertEqual(local.tree.label, "Root Updated") + + let jumped = NetworkConfig( + networkName: "TacNet Alpha", + networkID: networkID, + createdBy: "organizer-device", + pinHash: "pinhash", + version: 5, + tree: TreeNode(id: "root", label: "Fresh", claimedBy: nil, children: []) + ) + XCTAssertTrue(local.mergeIfNewer(jumped), "Higher versions must be accepted even if > local + 1") + XCTAssertEqual(local.version, 5) + XCTAssertEqual(local.tree.label, "Fresh") + } + + func testMessageEnvelopeSerializationHasAllRequiredFields() throws { + let message = Message.make( + type: .broadcast, + senderID: "device-alpha", + senderRole: "leaf", + parentID: "parent-1", + treeLevel: 2, + ttl: 4, + encrypted: false, + latitude: 37.3318, + longitude: -122.0312, + accuracy: 5.0, + transcript: "CONTACT front", + summary: nil, + claimedNodeID: nil, + targetNodeID: nil, + rejectionReason: nil, + tree: nil, + timestamp: Date(timeIntervalSince1970: 1_700_000_000) + ) + + let encoded = try JSONEncoder().encode(message) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: encoded) as? [String: Any]) + + let id = try XCTUnwrap(json["id"] as? String) + XCTAssertNotNil(UUID(uuidString: id), "id should encode as UUID string") + + XCTAssertTrue(json["type"] is String, "type should serialize as a string") + XCTAssertTrue(json["sender_id"] is String) + XCTAssertTrue(json["sender_role"] is String) + XCTAssertTrue(json["parent_id"] is String) + XCTAssertTrue(json["tree_level"] is NSNumber) + XCTAssertTrue(json["timestamp"] is NSNumber || json["timestamp"] is String) + XCTAssertTrue(json["ttl"] is NSNumber) + + let payload = try XCTUnwrap(json["payload"] as? [String: Any]) + XCTAssertTrue(payload["encrypted"] is Bool || payload["encrypted"] is NSNumber) + XCTAssertTrue(payload["transcript"] is String) + XCTAssertTrue(payload["location"] is [String: Any], "location must be present in payload") + } + + func testMessageTypeEnumCoverageAndUnknownTypeRejection() throws { + let decoder = JSONDecoder() + let encoder = JSONEncoder() + + try Message.MessageType.allCases.forEach { messageType in + let original = Message.make( + type: messageType, + senderID: "device-1", + senderRole: "participant", + parentID: "root", + treeLevel: 1, + ttl: 3, + encrypted: true, + latitude: 1.0, + longitude: 2.0, + accuracy: 3.0, + transcript: "sample", + summary: "sample-summary", + claimedNodeID: "node-1", + targetNodeID: "node-2", + rejectionReason: "organiser_wins", + tree: TreeNode(id: "root", label: "Root", claimedBy: nil, children: []), + timestamp: Date(timeIntervalSince1970: 1_700_000_001) + ) + + let roundTripped = try decoder.decode(Message.self, from: encoder.encode(original)) + XCTAssertEqual(roundTripped.type, messageType) + } + + let unknownTypeJSON = """ + { + "id":"11111111-2222-3333-4444-555555555555", + "type":"UNKNOWN_TYPE", + "sender_id":"device-1", + "sender_role":"participant", + "parent_id":"root", + "tree_level":1, + "timestamp":1700000001, + "ttl":3, + "payload":{ + "location":{"lat":0,"lon":0,"accuracy":-1,"is_fallback":true}, + "encrypted":false + } + } + """ + + XCTAssertThrowsError(try decoder.decode(Message.self, from: Data(unknownTypeJSON.utf8))) { error in + XCTAssertTrue(error is DecodingError) + } + } + + func testNodeIdentityPersistsAcrossSimulatedRestartAndCanBeCleared() throws { + let suiteName = "TacNetTests.NodeIdentity.\(UUID().uuidString)" + let defaultsA = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defaultsA.removePersistentDomain(forName: suiteName) + defer { defaultsA.removePersistentDomain(forName: suiteName) } + + let storeA = NodeIdentityStore(defaults: defaultsA) + let identity = NodeIdentity( + deviceID: "device-xyz", + claimedNodeID: "node-007", + networkID: UUID(uuidString: "AAAAAAAA-1111-2222-3333-BBBBBBBBBBBB")! + ) + + XCTAssertNoThrow(try storeA.save(identity)) + + let defaultsB = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + let storeB = NodeIdentityStore(defaults: defaultsB) + XCTAssertEqual(storeB.load(), identity, "Identity should survive simulated relaunch") + + storeB.clear() + XCTAssertNil(storeA.load(), "Cleared identity should be nil on next read") + } + + func testGPSCoordinateEmbeddingUsesLiveValuesAndFallbackWhenUnavailable() throws { + let withLocation = Message.make( + type: .broadcast, + senderID: "device-live", + senderRole: "leaf", + parentID: "parent-2", + treeLevel: 2, + ttl: 5, + encrypted: false, + latitude: 34.0522, + longitude: -118.2437, + accuracy: 4.5, + transcript: "Movement east", + summary: nil, + claimedNodeID: nil, + targetNodeID: nil, + rejectionReason: nil, + tree: nil + ) + + XCTAssertEqual(withLocation.payload.location.lat, 34.0522, accuracy: 0.000001) + XCTAssertEqual(withLocation.payload.location.lon, -118.2437, accuracy: 0.000001) + XCTAssertEqual(withLocation.payload.location.accuracy, 4.5, accuracy: 0.000001) + XCTAssertFalse(withLocation.payload.location.isFallback) + + let withoutLocation = Message.make( + type: .broadcast, + senderID: "device-fallback", + senderRole: "leaf", + parentID: "parent-2", + treeLevel: 2, + ttl: 5, + encrypted: false, + latitude: nil, + longitude: nil, + accuracy: nil, + transcript: "Fallback position", + summary: nil, + claimedNodeID: nil, + targetNodeID: nil, + rejectionReason: nil, + tree: nil + ) + + XCTAssertTrue(withoutLocation.payload.location.isFallback, "Fallback GPS should be flagged") + + let encoded = try JSONEncoder().encode(withoutLocation) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: encoded) as? [String: Any]) + let payload = try XCTUnwrap(json["payload"] as? [String: Any]) + XCTAssertNotNil(payload["location"], "location field must be present even without live GPS") + } + + func testMessageRouterBroadcastRoutingSiblingReceives() { + let tree = makeFixtureTree() + let router = MessageRouter() + let message = Message.make( + type: .broadcast, + senderID: "alpha-1", + senderRole: "participant", + parentID: "alpha", + treeLevel: 2, + ttl: 4, + encrypted: false, + latitude: nil, + longitude: nil, + accuracy: nil, + transcript: "Contact east" + ) + + XCTAssertTrue(router.shouldDisplay(message, for: "alpha-2", in: tree)) + } + + func testMessageRouterBroadcastRoutingParentReceives() { + let tree = makeFixtureTree() + let router = MessageRouter() + let message = Message.make( + type: .broadcast, + senderID: "alpha-1", + senderRole: "participant", + parentID: "alpha", + treeLevel: 2, + ttl: 4, + encrypted: false, + latitude: nil, + longitude: nil, + accuracy: nil, + transcript: "Contact east" + ) + + XCTAssertTrue(router.shouldDisplay(message, for: "alpha", in: tree)) + } + + func testMessageRouterBroadcastRoutingGrandparentExcluded() { + let tree = makeFixtureTree() + let router = MessageRouter() + let message = Message.make( + type: .broadcast, + senderID: "alpha-1", + senderRole: "participant", + parentID: "alpha", + treeLevel: 2, + ttl: 4, + encrypted: false, + latitude: nil, + longitude: nil, + accuracy: nil, + transcript: "Contact east" + ) + + XCTAssertFalse(router.shouldDisplay(message, for: "root", in: tree)) + } + + func testMessageRouterBroadcastRoutingCousinExcluded() { + let tree = makeFixtureTree() + let router = MessageRouter() + let message = Message.make( + type: .broadcast, + senderID: "alpha-1", + senderRole: "participant", + parentID: "alpha", + treeLevel: 2, + ttl: 4, + encrypted: false, + latitude: nil, + longitude: nil, + accuracy: nil, + transcript: "Contact east" + ) + + XCTAssertFalse(router.shouldDisplay(message, for: "charlie-1", in: tree)) + } + + func testMessageRouterCompactionRoutingOnlyParentReceives() { + let tree = makeFixtureTree() + let router = MessageRouter() + let message = Message.make( + type: .compaction, + senderID: "alpha", + senderRole: "participant", + parentID: "root", + treeLevel: 1, + ttl: 4, + encrypted: false, + latitude: nil, + longitude: nil, + accuracy: nil, + summary: "Alpha reports contact east" + ) + + XCTAssertTrue(router.shouldDisplay(message, for: "root", in: tree)) + XCTAssertFalse(router.shouldDisplay(message, for: "bravo", in: tree)) + XCTAssertFalse(router.shouldDisplay(message, for: "charlie", in: tree)) + } + + func testMessageRouterConstructsOutgoingEnvelopeWithRequiredFieldsAndGPS() throws { + let tree = makeFixtureTree() + let router = MessageRouter( + defaultTTL: 0, + gpsProvider: { + MessageRouter.GPSReading(latitude: 47.6205, longitude: -122.3493, accuracy: 3.2) + } + ) + + let outgoing = router.makeBroadcastMessage( + transcript: "Contact east", + senderID: "device-alpha-1", + senderNodeID: "alpha-1", + senderRole: "leaf", + in: tree + ) + + XCTAssertEqual(outgoing.type, .broadcast) + XCTAssertEqual(outgoing.senderID, "device-alpha-1") + XCTAssertEqual(outgoing.senderRole, "leaf") + XCTAssertEqual(outgoing.parentID, "alpha") + XCTAssertEqual(outgoing.treeLevel, 2) + XCTAssertGreaterThan(outgoing.ttl, 0, "Outgoing envelope must always have ttl > 0") + XCTAssertEqual(outgoing.payload.transcript, "Contact east") + XCTAssertNil(outgoing.payload.summary) + XCTAssertEqual(outgoing.payload.location.lat, 47.6205, accuracy: 0.000001) + XCTAssertEqual(outgoing.payload.location.lon, -122.3493, accuracy: 0.000001) + XCTAssertEqual(outgoing.payload.location.accuracy, 3.2, accuracy: 0.000001) + XCTAssertFalse(outgoing.payload.location.isFallback) + + let encoded = try JSONEncoder().encode(outgoing) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: encoded) as? [String: Any]) + let idString = try XCTUnwrap(json["id"] as? String) + XCTAssertNotNil(UUID(uuidString: idString), "Outgoing id must be a valid UUID string") + XCTAssertNotNil(json["timestamp"], "Outgoing envelope must include a timestamp") + XCTAssertGreaterThan((json["ttl"] as? NSNumber)?.intValue ?? 0, 0) + + let payload = try XCTUnwrap(json["payload"] as? [String: Any]) + let location = try XCTUnwrap(payload["location"] as? [String: Any]) + XCTAssertTrue(location["lat"] is NSNumber) + XCTAssertTrue(location["lon"] is NSNumber) + XCTAssertTrue(location["accuracy"] is NSNumber) + } + + func testTreeHelpersParentLookupForRootLeafIntermediateAndMissing() { + let tree = makeFixtureTree() + + XCTAssertEqual(TreeHelpers.parent(of: "alpha", in: tree)?.id, "root") + XCTAssertEqual(TreeHelpers.parent(of: "alpha-1", in: tree)?.id, "alpha") + XCTAssertNil(TreeHelpers.parent(of: "root", in: tree)) + XCTAssertNil(TreeHelpers.parent(of: "missing", in: tree)) + } + + func testTreeHelpersSiblingsLookupExcludesSelfAndHandlesRootSingleChildAndMissing() { + let tree = makeFixtureTree() + + XCTAssertEqual(TreeHelpers.siblings(of: "alpha-1", in: tree).map(\.id), ["alpha-2"]) + XCTAssertEqual(TreeHelpers.siblings(of: "alpha", in: tree).map(\.id), ["bravo", "charlie"]) + XCTAssertEqual(TreeHelpers.siblings(of: "charlie-1", in: tree).count, 0, "Single child should have no siblings") + XCTAssertEqual(TreeHelpers.siblings(of: "root", in: tree).count, 0, "Root should have no siblings") + XCTAssertEqual(TreeHelpers.siblings(of: "missing", in: tree).count, 0, "Missing node should produce no siblings") + } + + func testTreeHelpersChildrenLookupForRootIntermediateLeafAndMissing() { + let tree = makeFixtureTree() + + XCTAssertEqual(TreeHelpers.children(of: "root", in: tree).map(\.id), ["alpha", "bravo", "charlie"]) + XCTAssertEqual(TreeHelpers.children(of: "alpha", in: tree).map(\.id), ["alpha-1", "alpha-2"]) + XCTAssertEqual(TreeHelpers.children(of: "bravo", in: tree).count, 0, "Leaf should have no children") + XCTAssertEqual(TreeHelpers.children(of: "missing", in: tree).count, 0, "Missing node should produce no children") + } + + func testTreeHelpersLevelLookupForRootIntermediateLeafAndMissing() { + let tree = makeFixtureTree() + + XCTAssertEqual(TreeHelpers.level(of: "root", in: tree), 0) + XCTAssertEqual(TreeHelpers.level(of: "alpha", in: tree), 1) + XCTAssertEqual(TreeHelpers.level(of: "alpha-1", in: tree), 2) + XCTAssertNil(TreeHelpers.level(of: "missing", in: tree)) + } + + func testMessageDeduplicatorReturnsFalseForFirstSeenAndTrueForReseen() { + let deduplicator = MessageDeduplicator(capacity: 50_000) + let id = UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")! + + XCTAssertFalse(deduplicator.isDuplicate(messageId: id)) + XCTAssertTrue(deduplicator.isDuplicate(messageId: id)) + } + + func testMessageDeduplicatorDifferentUUIDsAreNotDuplicates() { + let deduplicator = MessageDeduplicator(capacity: 50_000) + let idA = UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")! + let idB = UUID(uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB")! + + XCTAssertFalse(deduplicator.isDuplicate(messageId: idA)) + XCTAssertFalse(deduplicator.isDuplicate(messageId: idB)) + } + + func testMessageDeduplicatorStress10kEntriesMaintainsCorrectness() { + let deduplicator = MessageDeduplicator(capacity: 50_000) + let ids = (0..<10_000).map(deterministicUUID(from:)) + + ids.forEach { id in + XCTAssertFalse(deduplicator.isDuplicate(messageId: id), "First-seen UUID must not be duplicate") + } + + ids.forEach { id in + XCTAssertTrue(deduplicator.isDuplicate(messageId: id), "Re-seen UUID must be duplicate") + } + } + + func testMessageDeduplicatorBoundedGrowthAndRecentEntriesRemainTracked() { + let capacity = 1_000 + let deduplicator = MessageDeduplicator(capacity: capacity) + let ids = (0..<(capacity * 2)).map(deterministicUUID(from:)) + + ids.forEach { id in + XCTAssertFalse(deduplicator.isDuplicate(messageId: id)) + } + + XCTAssertEqual(deduplicator.trackedCount, capacity, "Seen-set size should remain bounded by capacity") + + ids.suffix(1_000).forEach { id in + XCTAssertTrue(deduplicator.isDuplicate(messageId: id), "Most-recent IDs should still be tracked") + } + + XCTAssertFalse(deduplicator.isDuplicate(messageId: ids[0]), "Oldest entries should be evicted after overflow") + } + + func testBluetoothMeshUUIDDefinitionsMatchExpectedValues() { + XCTAssertEqual(BluetoothMeshUUIDs.service.uuidString.uppercased(), "7B4D8C10-3A8E-4D1A-9F53-2E28D9C1A001") + XCTAssertEqual(BluetoothMeshUUIDs.broadcastCharacteristic.uuidString.uppercased(), "7B4D8C10-3A8E-4D1A-9F53-2E28D9C1A101") + XCTAssertEqual(BluetoothMeshUUIDs.compactionCharacteristic.uuidString.uppercased(), "7B4D8C10-3A8E-4D1A-9F53-2E28D9C1A102") + XCTAssertEqual(BluetoothMeshUUIDs.treeConfigCharacteristic.uuidString.uppercased(), "7B4D8C10-3A8E-4D1A-9F53-2E28D9C1A103") + } + + func testBluetoothMeshServiceFloodsLocalMessagesToAllConnectedPeers() throws { + let transport = MockBluetoothMeshTransport() + let service = BluetoothMeshService(transport: transport, deduplicator: MessageDeduplicator(capacity: 1_000)) + + let peerA = UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")! + let peerB = UUID(uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB")! + transport.emit(.connectionStateChanged(peerA, .connected)) + transport.emit(.connectionStateChanged(peerB, .connected)) + + let message = makeMeshMessage( + id: UUID(uuidString: "11111111-1111-1111-1111-111111111111")!, + ttl: 3 + ) + service.publish(message) + + XCTAssertEqual(transport.sentPackets.count, 1) + let sent = try XCTUnwrap(transport.sentPackets.first) + XCTAssertEqual(sent.peerIDs, Set([peerA, peerB])) + + let forwarded = try decodeMessage(from: sent.data) + XCTAssertEqual(forwarded.id, message.id) + XCTAssertEqual(forwarded.ttl, 3, "Locally-originated message should keep original TTL on first flood") + } + + func testBluetoothMeshServiceDecrementsTTLOnReceiveAndRelaysToOtherPeers() throws { + let transport = MockBluetoothMeshTransport() + let service = BluetoothMeshService(transport: transport, deduplicator: MessageDeduplicator(capacity: 1_000)) + + let sourcePeer = UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")! + let relayPeer1 = UUID(uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB")! + let relayPeer2 = UUID(uuidString: "CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC")! + transport.emit(.connectionStateChanged(sourcePeer, .connected)) + transport.emit(.connectionStateChanged(relayPeer1, .connected)) + transport.emit(.connectionStateChanged(relayPeer2, .connected)) + + var receivedLocally: [Message] = [] + service.onMessageReceived = { receivedLocally.append($0) } + + let inbound = makeMeshMessage( + id: UUID(uuidString: "22222222-2222-2222-2222-222222222222")!, + ttl: 2 + ) + let inboundData = try JSONEncoder().encode(inbound) + transport.emit(.receivedData(inboundData, from: sourcePeer)) + + XCTAssertEqual(receivedLocally.count, 1) + XCTAssertEqual(receivedLocally[0].ttl, 1, "TTL must decrement by one hop on receipt") + + XCTAssertEqual(transport.sentPackets.count, 1) + let relayPacket = try XCTUnwrap(transport.sentPackets.first) + XCTAssertEqual(relayPacket.peerIDs, Set([relayPeer1, relayPeer2]), "Relay should exclude the source peer") + + let relayed = try decodeMessage(from: relayPacket.data) + XCTAssertEqual(relayed.ttl, 1) + } + + func testBluetoothMeshServiceProcessesTTL1LocallyAndDoesNotRelay() throws { + let transport = MockBluetoothMeshTransport() + let service = BluetoothMeshService(transport: transport, deduplicator: MessageDeduplicator(capacity: 1_000)) + + let sourcePeer = UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")! + let otherPeer = UUID(uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB")! + transport.emit(.connectionStateChanged(sourcePeer, .connected)) + transport.emit(.connectionStateChanged(otherPeer, .connected)) + + var receivedLocally: [Message] = [] + service.onMessageReceived = { receivedLocally.append($0) } + + let inbound = makeMeshMessage( + id: UUID(uuidString: "33333333-3333-3333-3333-333333333333")!, + ttl: 1 + ) + let inboundData = try JSONEncoder().encode(inbound) + transport.emit(.receivedData(inboundData, from: sourcePeer)) + + XCTAssertEqual(receivedLocally.count, 1) + XCTAssertEqual(receivedLocally[0].ttl, 0, "TTL=1 should become TTL=0 after processing this hop") + XCTAssertEqual(transport.sentPackets.count, 0, "TTL reaching 0 must not be re-broadcast") + } + + func testBluetoothMeshServiceDropsDuplicateInboundMessages() throws { + let transport = MockBluetoothMeshTransport() + let service = BluetoothMeshService(transport: transport, deduplicator: MessageDeduplicator(capacity: 1_000)) + + let sourcePeer = UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")! + let relayPeer = UUID(uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB")! + transport.emit(.connectionStateChanged(sourcePeer, .connected)) + transport.emit(.connectionStateChanged(relayPeer, .connected)) + + var receivedLocally: [Message] = [] + service.onMessageReceived = { receivedLocally.append($0) } + + let messageID = UUID(uuidString: "44444444-4444-4444-4444-444444444444")! + let inbound = makeMeshMessage(id: messageID, ttl: 3) + let inboundData = try JSONEncoder().encode(inbound) + + transport.emit(.receivedData(inboundData, from: sourcePeer)) + transport.emit(.receivedData(inboundData, from: relayPeer)) + + XCTAssertEqual(receivedLocally.count, 1, "Duplicate UUID should be ignored") + XCTAssertEqual(transport.sentPackets.count, 1, "Duplicate UUID should not be re-broadcast") + } + + func testBluetoothMeshServiceStoreAndForwardFlushesOnConnect() { + let transport = MockBluetoothMeshTransport() + let service = BluetoothMeshService(transport: transport, deduplicator: MessageDeduplicator(capacity: 1_000)) + + let pending = makeMeshMessage( + id: UUID(uuidString: "55555555-5555-5555-5555-555555555555")!, + ttl: 3 + ) + service.publish(pending) + + XCTAssertEqual(transport.sentPackets.count, 0, "Message should be queued when there are no connected peers") + XCTAssertEqual(service.pendingRelayCount, 1) + + let peer = UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")! + transport.emit(.connectionStateChanged(peer, .connected)) + + XCTAssertEqual(transport.sentPackets.count, 1, "Queued messages should flush when a peer connects") + XCTAssertEqual(transport.sentPackets[0].peerIDs, Set([peer])) + XCTAssertEqual(service.pendingRelayCount, 0) + } + + func testBluetoothMeshServiceTracksPeerConnectionStateTransitions() { + let transport = MockBluetoothMeshTransport() + let service = BluetoothMeshService(transport: transport, deduplicator: MessageDeduplicator(capacity: 1_000)) + + let peer = UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")! + XCTAssertEqual(service.connectionState(for: peer), .disconnected) + XCTAssertTrue(service.connectedPeerIDs.isEmpty) + + transport.emit(.connectionStateChanged(peer, .connected)) + XCTAssertEqual(service.connectionState(for: peer), .connected) + XCTAssertEqual(service.connectedPeerIDs, Set([peer])) + + transport.emit(.connectionStateChanged(peer, .disconnected)) + XCTAssertEqual(service.connectionState(for: peer), .disconnected) + XCTAssertTrue(service.connectedPeerIDs.isEmpty) + } + + func testModelDownloadServiceReportsMonotonicProgressWithAtLeastFiveIntermediateCallbacksAndUnlocksGate() async throws { + let sandbox = try makeModelDownloadSandbox() + defer { sandbox.cleanup() } + + let temporaryModelFile = try makeTemporaryModelFile(in: sandbox.baseDirectory) + let mockSession = MockURLSessionDownloadClient( + scriptedResponses: [ + .init( + progressEvents: [(50, 1_000), (200, 1_000), (700, 1_000), (1_000, 1_000)], + result: .success(temporaryModelFile) + ) + ] + ) + + let service = makeModelDownloadService( + sandbox: sandbox, + downloader: mockSession, + availableStorageBytes: 20_000_000_000 + ) + + let gateLockedBeforeDownload = await service.canUseTacticalFeatures() + XCTAssertFalse(gateLockedBeforeDownload, "App should stay gated before model download completes") + + let progressCollector = LockedArray() + _ = try await service.ensureModelAvailable { progress in + progressCollector.append(progress) + } + let reportedProgress = progressCollector.values + + let gateUnlockedAfterDownload = await service.canUseTacticalFeatures() + XCTAssertTrue(gateUnlockedAfterDownload, "Gate should unlock once model download completes") + XCTAssertTrue(reportedProgress.allSatisfy { $0 >= 0 && $0 <= 1 }, "Progress must stay inside [0, 1]") + XCTAssertTrue( + zip(reportedProgress, reportedProgress.dropFirst()).allSatisfy { lhs, rhs in lhs <= rhs + 0.000_000_1 }, + "Progress callbacks must be monotonic" + ) + let finalProgress = try XCTUnwrap(reportedProgress.last) + XCTAssertEqual(finalProgress, 1.0, accuracy: 0.000_001) + + let intermediateCallbacks = reportedProgress.filter { $0 > 0 && $0 < 1 } + XCTAssertGreaterThanOrEqual(intermediateCallbacks.count, 5, "Need at least 5 intermediate progress callbacks") + + XCTAssertEqual(mockSession.requestCount, 1) + XCTAssertNil(mockSession.request(at: 0)?.resumeData, "Initial attempt should start without resume data") + } + + func testModelDownloadServiceFailsBeforeNetworkWhenStorageIsInsufficient() async throws { + let sandbox = try makeModelDownloadSandbox() + defer { sandbox.cleanup() } + + let mockSession = MockURLSessionDownloadClient(scriptedResponses: []) + let service = makeModelDownloadService( + sandbox: sandbox, + downloader: mockSession, + availableStorageBytes: 1_000_000_000 + ) + + do { + _ = try await service.ensureModelAvailable() + XCTFail("Expected insufficient storage error") + } catch let error as ModelDownloadServiceError { + guard case let .insufficientStorage(requiredBytes, availableBytes) = error else { + return XCTFail("Expected insufficientStorage, got \(error)") + } + XCTAssertEqual(requiredBytes, makeModelDownloadConfiguration().expectedModelSizeBytes) + XCTAssertEqual(availableBytes, 1_000_000_000) + } + + XCTAssertEqual(mockSession.requestCount, 0, "Network must not start when storage check fails") + let gateState = await service.canUseTacticalFeatures() + XCTAssertFalse(gateState) + let modelDirectory = sandbox.appSupportDirectory + .appendingPathComponent(makeModelDownloadConfiguration().modelDirectoryName, isDirectory: true) + XCTAssertFalse( + FileManager.default.fileExists(atPath: modelDirectory.path), + "Storage precheck failure should not leave partial model files or folders" + ) + } + + func testModelDownloadServiceResumesUsingStoredResumeDataAfterInterruption() async throws { + let sandbox = try makeModelDownloadSandbox() + defer { sandbox.cleanup() } + + let resumeData = Data("resume-point".utf8) + let temporaryModelFile = try makeTemporaryModelFile(in: sandbox.baseDirectory) + let mockSession = MockURLSessionDownloadClient( + scriptedResponses: [ + .init( + progressEvents: [(250, 1_000)], + result: .failure(URLSessionDownloadClientError.interrupted(resumeData: resumeData)) + ), + .init( + progressEvents: [(900, 1_000), (1_000, 1_000)], + result: .success(temporaryModelFile) + ) + ] + ) + + let service = makeModelDownloadService( + sandbox: sandbox, + downloader: mockSession, + availableStorageBytes: 20_000_000_000 + ) + + do { + _ = try await service.ensureModelAvailable() + XCTFail("Expected first attempt to be interrupted") + } catch let error as ModelDownloadServiceError { + guard case let .interrupted(canResume) = error else { + return XCTFail("Expected interrupted error, got \(error)") + } + XCTAssertTrue(canResume, "Interrupted errors should indicate resumable state when resumeData exists") + } + + XCTAssertEqual(mockSession.requestCount, 1) + XCTAssertNil(mockSession.request(at: 0)?.resumeData) + + _ = try await service.ensureModelAvailable() + + XCTAssertEqual(mockSession.requestCount, 2) + XCTAssertEqual(mockSession.request(at: 1)?.resumeData, resumeData, "Second attempt should use stored resume data") + let gateState = await service.canUseTacticalFeatures() + XCTAssertTrue(gateState) + } + + func testEnsureModelAvailableRejectsNonZipPayloadInProductionMode() async throws { + let sandbox = try makeModelDownloadSandbox() + defer { sandbox.cleanup() } + + // 19 bytes of non-zip content ("Access denied\n") mimic a real HTTP + // error body that a naïve implementation would happily promote to the + // sentinel path — leading to Cactus crashing at load time. In + // production mode (requiresZipArchive == true) the service MUST + // reject this payload. + let accessDeniedURL = sandbox.baseDirectory + .appendingPathComponent("access-denied-\(UUID().uuidString).bin") + let errorBody = Data("Access denied 123!\n".utf8) + XCTAssertEqual(errorBody.count, 19, "Test precondition: simulate 19 bytes of HTTP error body") + try errorBody.write(to: accessDeniedURL, options: .atomic) + + let mockSession = MockURLSessionDownloadClient( + scriptedResponses: [ + .init( + progressEvents: [(19, 19)], + result: .success(accessDeniedURL) + ) + ] + ) + + let service = makeModelDownloadService( + sandbox: sandbox, + downloader: mockSession, + availableStorageBytes: 20_000_000_000, + requiresZipArchive: true + ) + + do { + _ = try await service.ensureModelAvailable() + XCTFail("Expected invalidArchive error when a non-zip payload is served in production mode") + } catch let error as ModelDownloadServiceError { + XCTAssertEqual(error, .invalidArchive, "Non-zip payload in production mode must throw .invalidArchive") + } + + let gateStillLocked = await service.canUseTacticalFeatures() + XCTAssertFalse(gateStillLocked, "Gate must remain closed after a rejected non-zip payload") + let reportedDirectoryPath = await service.downloadedModelDirectoryPath() + XCTAssertNil( + reportedDirectoryPath, + "No downloaded model path should be reported after a rejected non-zip payload" + ) + + let modelDirectory = sandbox.appSupportDirectory + .appendingPathComponent(makeModelDownloadConfiguration().modelDirectoryName, isDirectory: true) + let sentinelURL = modelDirectory.appendingPathComponent(makeModelDownloadConfiguration().modelFileName, isDirectory: false) + XCTAssertFalse( + FileManager.default.fileExists(atPath: sentinelURL.path), + "Sentinel file must NOT exist after a rejected non-zip payload" + ) + } + + func testEnsureModelAvailableAcceptsNonZipPayloadInTestMode() async throws { + let sandbox = try makeModelDownloadSandbox() + defer { sandbox.cleanup() } + + // Mirror the fixture pattern used by the other existing model-download + // tests: a tiny non-zip "model" file. In test mode + // (requiresZipArchive == false) this path remains supported so the + // existing mock-based coverage keeps working. + let temporaryModelFile = try makeTemporaryModelFile(in: sandbox.baseDirectory) + let mockSession = MockURLSessionDownloadClient( + scriptedResponses: [ + .init( + progressEvents: [(500, 1_000), (1_000, 1_000)], + result: .success(temporaryModelFile) + ) + ] + ) + + let service = makeModelDownloadService( + sandbox: sandbox, + downloader: mockSession, + availableStorageBytes: 20_000_000_000, + requiresZipArchive: false + ) + + _ = try await service.ensureModelAvailable() + + let gateUnlocked = await service.canUseTacticalFeatures() + XCTAssertTrue(gateUnlocked, "Test-mode non-zip payload should still unlock the gate") + + let resolvedDirectoryPath = await service.downloadedModelDirectoryPath() + let directoryPath = try XCTUnwrap(resolvedDirectoryPath) + let sentinelURL = URL(fileURLWithPath: directoryPath) + .appendingPathComponent(makeModelDownloadConfiguration().modelFileName, isDirectory: false) + XCTAssertTrue( + FileManager.default.fileExists(atPath: sentinelURL.path), + "Test-mode non-zip install should persist the sentinel file" + ) + } + + func testCactusModelInitializationIsGatedUntilDownloadCompletesThenUsesDownloadedPath() async throws { + let sandbox = try makeModelDownloadSandbox() + defer { sandbox.cleanup() } + + let temporaryModelFile = try makeTemporaryModelFile(in: sandbox.baseDirectory) + let mockSession = MockURLSessionDownloadClient( + scriptedResponses: [ + .init( + progressEvents: [(500, 1_000), (1_000, 1_000)], + result: .success(temporaryModelFile) + ) + ] + ) + + let service = makeModelDownloadService( + sandbox: sandbox, + downloader: mockSession, + availableStorageBytes: 20_000_000_000 + ) + + let initPathCollector = LockedArray() + let expectedHandleAddress = UInt(bitPattern: 0xFEEDBEEF) + let initializer = CactusModelInitializationService( + downloadService: service, + initFunction: { modelPath, _, _ in + initPathCollector.append(modelPath) + return UnsafeMutableRawPointer(bitPattern: expectedHandleAddress)! + }, + destroyFunction: { _ in } + ) + + do { + _ = try await initializer.initializeModel() + XCTFail("Expected download gate to block initialization before model is downloaded") + } catch let error as CactusModelInitializationError { + guard case .downloadIncomplete = error else { + return XCTFail("Expected downloadIncomplete error, got \(error)") + } + } + + XCTAssertTrue(initPathCollector.values.isEmpty, "cactusInit should not run while download gate is locked") + + _ = try await service.ensureModelAvailable() + let handle = try await initializer.initializeModel() + XCTAssertEqual(handle, UnsafeMutableRawPointer(bitPattern: expectedHandleAddress)) + let initPaths = initPathCollector.values + XCTAssertEqual(initPaths.count, 1) + let downloadedDirectory = await service.downloadedModelDirectoryPath() + XCTAssertEqual(initPaths.first, downloadedDirectory) + } + + func testAppBootstrapViewModelShowsModelNamePercentageAndBytesDuringDownload() async throws { + let sandbox = try makeModelDownloadSandbox() + defer { sandbox.cleanup() } + + let temporaryModelFile = try makeTemporaryModelFile(in: sandbox.baseDirectory) + let mockSession = MockURLSessionDownloadClient( + scriptedResponses: [ + .init( + progressEvents: [(500, 1_000)], + result: .success(temporaryModelFile), + responseDelayNanoseconds: 300_000_000 + ) + ] + ) + + let service = makeModelDownloadService( + sandbox: sandbox, + downloader: mockSession, + availableStorageBytes: 20_000_000_000 + ) + + let viewModel = await MainActor.run { AppBootstrapViewModel(downloadService: service) } + await MainActor.run { viewModel.startIfNeeded() } + + let reachedMidDownload = await waitForCondition(timeout: 1.0) { + await MainActor.run { viewModel.downloadProgress >= 0.5 && !viewModel.isDownloadComplete } + } + XCTAssertTrue(reachedMidDownload, "Expected in-progress state before download finishes") + + let modelName = await MainActor.run { viewModel.modelName } + let percentLabel = await MainActor.run { viewModel.progressLabel } + let bytesLabel = await MainActor.run { viewModel.byteProgressLabel } + XCTAssertTrue(modelName.contains("Gemma"), "Download UI should show model name") + XCTAssertTrue(percentLabel.contains("%"), "Download UI should show progress percentage") + XCTAssertTrue(bytesLabel.contains("/"), "Download UI should show downloaded and total bytes") + } + + func testAppBootstrapViewModelBlocksTacticalFeaturesDuringDownloadAndUnlocksWithinThreeSeconds() async throws { + let sandbox = try makeModelDownloadSandbox() + defer { sandbox.cleanup() } + + let temporaryModelFile = try makeTemporaryModelFile(in: sandbox.baseDirectory) + let mockSession = MockURLSessionDownloadClient( + scriptedResponses: [ + .init( + progressEvents: [(100, 1_000), (600, 1_000)], + result: .success(temporaryModelFile), + responseDelayNanoseconds: 500_000_000 + ) + ] + ) + + let service = makeModelDownloadService( + sandbox: sandbox, + downloader: mockSession, + availableStorageBytes: 20_000_000_000 + ) + + let viewModel = await MainActor.run { AppBootstrapViewModel(downloadService: service) } + await MainActor.run { viewModel.startIfNeeded() } + + let initiallyBlocked = await MainActor.run { !viewModel.isDownloadComplete } + XCTAssertTrue(initiallyBlocked, "Tactical features must be blocked until download completes") + + let unlockedWithinThreeSeconds = await waitForCondition(timeout: 3.0) { + await MainActor.run { viewModel.isDownloadComplete } + } + XCTAssertTrue(unlockedWithinThreeSeconds, "Features should unlock within 3 seconds of completion") + } + + func testAppBootstrapViewModelShowsClearStorageErrorBeforeDownloadStarts() async throws { + let sandbox = try makeModelDownloadSandbox() + defer { sandbox.cleanup() } + + let mockSession = MockURLSessionDownloadClient(scriptedResponses: []) + let service = makeModelDownloadService( + sandbox: sandbox, + downloader: mockSession, + availableStorageBytes: 1_000_000_000 + ) + + let viewModel = await MainActor.run { AppBootstrapViewModel(downloadService: service) } + await MainActor.run { viewModel.startIfNeeded() } + + let errorAppeared = await waitForCondition(timeout: 1.0) { + await MainActor.run { viewModel.errorMessage != nil } + } + XCTAssertTrue(errorAppeared, "Download gate should show an error when storage is insufficient") + + let errorMessage = await MainActor.run { viewModel.errorMessage } + let resolvedMessage = try XCTUnwrap(errorMessage) + XCTAssertTrue(resolvedMessage.contains("Insufficient storage")) + XCTAssertTrue(resolvedMessage.contains("Free up")) + XCTAssertEqual(mockSession.requestCount, 0, "Storage failure should happen before any network requests") + let gateStillLocked = await MainActor.run { !viewModel.isDownloadComplete } + XCTAssertTrue(gateStillLocked) + } + + func testAppBootstrapViewModelRetryResumesInterruptedDownloadFromPriorPoint() async throws { + let sandbox = try makeModelDownloadSandbox() + defer { sandbox.cleanup() } + + let resumeData = Data("resume-checkpoint".utf8) + let temporaryModelFile = try makeTemporaryModelFile(in: sandbox.baseDirectory) + let mockSession = MockURLSessionDownloadClient( + scriptedResponses: [ + .init( + progressEvents: [(620, 1_000)], + result: .failure(URLSessionDownloadClientError.interrupted(resumeData: resumeData)) + ), + .init( + progressEvents: [(700, 1_000), (1_000, 1_000)], + result: .success(temporaryModelFile) + ) + ] + ) + + let service = makeModelDownloadService( + sandbox: sandbox, + downloader: mockSession, + availableStorageBytes: 20_000_000_000 + ) + + let viewModel = await MainActor.run { AppBootstrapViewModel(downloadService: service) } + await MainActor.run { viewModel.startIfNeeded() } + + let interruptionSurfaced = await waitForCondition(timeout: 1.0) { + await MainActor.run { viewModel.errorMessage != nil } + } + XCTAssertTrue(interruptionSurfaced) + + let progressAtInterruption = await MainActor.run { viewModel.downloadProgress } + XCTAssertGreaterThanOrEqual(progressAtInterruption, 0.60) + + await MainActor.run { viewModel.retry() } + let completedAfterRetry = await waitForCondition(timeout: 2.0) { + await MainActor.run { viewModel.isDownloadComplete } + } + XCTAssertTrue(completedAfterRetry) + XCTAssertEqual(mockSession.requestCount, 2) + XCTAssertEqual(mockSession.request(at: 1)?.resumeData, resumeData, "Retry should resume using stored resume data") + + let finalProgress = await MainActor.run { viewModel.downloadProgress } + XCTAssertGreaterThanOrEqual(finalProgress, progressAtInterruption, "Retry should continue from roughly the prior point") + } + + func testTreeBuilderAddNodeCreatesUniqueUnclaimedChildAndIncrementsVersion() throws { + let viewModel = TreeBuilderViewModel(networkName: "Operation Nightfall", createdBy: "organiser-device") + let initialVersion = viewModel.currentVersion + let rootID = viewModel.networkConfig.tree.id + + let firstChild = try XCTUnwrap(viewModel.addNode(parentID: rootID, label: "Alpha Lead")) + let secondChild = try XCTUnwrap(viewModel.addNode(parentID: rootID, label: "Bravo Lead")) + + XCTAssertEqual(viewModel.currentVersion, initialVersion + 2) + XCTAssertNotEqual(firstChild.id, secondChild.id, "Each added node should have a unique identifier") + XCTAssertNil(firstChild.claimedBy, "Newly added node should be unclaimed") + XCTAssertNil(secondChild.claimedBy, "Newly added node should be unclaimed") + + let rootChildren = TreeHelpers.children(of: rootID, in: viewModel.networkConfig.tree) + XCTAssertEqual(rootChildren.map(\.id), [firstChild.id, secondChild.id]) + + let serialized = try XCTUnwrap(viewModel.serializedTreeJSON()) + let decoded = try JSONDecoder().decode(TreeNode.self, from: Data(serialized.utf8)) + XCTAssertEqual(decoded.children.map(\.id), [firstChild.id, secondChild.id], "Tree JSON should preserve new children for BLE distribution") + } + + func testTreeBuilderRemoveNodeCascadesDescendantsAndIncrementsVersion() throws { + let viewModel = TreeBuilderViewModel(networkName: "Operation Nightfall", createdBy: "organiser-device") + let rootID = viewModel.networkConfig.tree.id + + let alpha = try XCTUnwrap(viewModel.addNode(parentID: rootID, label: "Alpha")) + let alpha1 = try XCTUnwrap(viewModel.addNode(parentID: alpha.id, label: "Alpha-1")) + let alpha2 = try XCTUnwrap(viewModel.addNode(parentID: alpha.id, label: "Alpha-2")) + + let versionBeforeRemove = viewModel.currentVersion + XCTAssertTrue(viewModel.removeNode(nodeID: alpha.id)) + XCTAssertEqual(viewModel.currentVersion, versionBeforeRemove + 1, "Successful remove should increment version by exactly one") + + XCTAssertNil(TreeHelpers.level(of: alpha.id, in: viewModel.networkConfig.tree)) + XCTAssertNil(TreeHelpers.level(of: alpha1.id, in: viewModel.networkConfig.tree)) + XCTAssertNil(TreeHelpers.level(of: alpha2.id, in: viewModel.networkConfig.tree)) + } + + func testTreeBuilderRenameNodeSupportsUnicodeEmojiAndIncrementsVersion() throws { + let viewModel = TreeBuilderViewModel(networkName: "Operation Nightfall", createdBy: "organiser-device") + let rootID = viewModel.networkConfig.tree.id + let node = try XCTUnwrap(viewModel.addNode(parentID: rootID, label: "Node")) + + let unicodeLabel = "🛰️ Recon 팀 – 北侧" + let versionBeforeRename = viewModel.currentVersion + XCTAssertTrue(viewModel.renameNode(nodeID: node.id, newLabel: unicodeLabel)) + XCTAssertEqual(viewModel.currentVersion, versionBeforeRename + 1, "Successful rename should increment version by exactly one") + + let renamedNode = try XCTUnwrap(viewModel.node(withID: node.id)) + XCTAssertEqual(renamedNode.label, unicodeLabel) + } + + func testTreeBuilderVersionIncrementsByOnePerSuccessfulOperation() throws { + let viewModel = TreeBuilderViewModel(networkName: "Operation Nightfall", createdBy: "organiser-device") + let rootID = viewModel.networkConfig.tree.id + + let start = viewModel.currentVersion + let child = try XCTUnwrap(viewModel.addNode(parentID: rootID, label: "Alpha")) + XCTAssertEqual(viewModel.currentVersion, start + 1) + + XCTAssertTrue(viewModel.renameNode(nodeID: child.id, newLabel: "Alpha 🟢")) + XCTAssertEqual(viewModel.currentVersion, start + 2) + + XCTAssertTrue(viewModel.removeNode(nodeID: child.id)) + XCTAssertEqual(viewModel.currentVersion, start + 3) + } + + func testTreeBuilderEmptyTreeHandlingIsGracefulAndSerializable() throws { + let viewModel = TreeBuilderViewModel(networkName: "Operation Nightfall", createdBy: "organiser-device") + XCTAssertFalse(viewModel.isTreeEmpty) + + viewModel.clearTree() + XCTAssertTrue(viewModel.isTreeEmpty, "Cleared tree should be represented as an empty state") + + let serialized = try XCTUnwrap(viewModel.serializedTreeJSON()) + let decoded = try JSONDecoder().decode(TreeNode.self, from: Data(serialized.utf8)) + XCTAssertEqual(decoded.children.count, 0) + XCTAssertEqual(decoded.label, "") + XCTAssertNil(decoded.claimedBy) + + let recoveredNode = try XCTUnwrap(viewModel.addNode(parentID: decoded.id, label: "Recovered Node")) + XCTAssertEqual(recoveredNode.label, "Recovered Node") + XCTAssertFalse(viewModel.isTreeEmpty) + } + + func testTreeBuilderDeepTreeFourPlusLevelsSerializesCorrectly() throws { + let viewModel = TreeBuilderViewModel(networkName: "Operation Nightfall", createdBy: "organiser-device") + let rootID = viewModel.networkConfig.tree.id + + let l1 = try XCTUnwrap(viewModel.addNode(parentID: rootID, label: "L1")) + let l2 = try XCTUnwrap(viewModel.addNode(parentID: l1.id, label: "L2")) + let l3 = try XCTUnwrap(viewModel.addNode(parentID: l2.id, label: "L3")) + let l4 = try XCTUnwrap(viewModel.addNode(parentID: l3.id, label: "L4")) + _ = try XCTUnwrap(viewModel.addNode(parentID: l4.id, label: "L5")) + + let serialized = try XCTUnwrap(viewModel.serializedTreeJSON()) + let decodedTree = try JSONDecoder().decode(TreeNode.self, from: Data(serialized.utf8)) + XCTAssertEqual(TreeHelpers.level(of: l4.id, in: decodedTree), 4, "Tree with 4+ levels should round-trip through JSON correctly") + } + + func testTreeBuilderNetworkIDUniquenessAcrossManyNetworks() { + let ids = Set((0..<1_000).map { index in + TreeBuilderViewModel( + networkName: "Net-\(index)", + createdBy: "organiser-\(index)" + ).networkConfig.networkID + }) + + XCTAssertEqual(ids.count, 1_000, "Each network should be assigned a unique UUID") + } + + func testTreeBuilderConcurrentEditsProduceMonotonicVersionsWithoutGaps() { + let viewModel = TreeBuilderViewModel(networkName: "Operation Nightfall", createdBy: "organiser-device") + let rootID = viewModel.networkConfig.tree.id + let startVersion = viewModel.currentVersion + let editCount = 40 + + let queue = DispatchQueue(label: "TacNet.TreeBuilder.ConcurrentEdits", attributes: .concurrent) + let group = DispatchGroup() + + for index in 0.. 0 }) + XCTAssertTrue(store.search(query: "nonsense-does-not-exist").isEmpty) + } + + @MainActor + func testAfterActionReviewViewModelSearchUpdatesResultsWithMetadata() { + let store = InMemoryAfterActionReviewStore() + let viewModel = AfterActionReviewViewModel(store: store) + + viewModel.record( + Message.make( + id: UUID(uuidString: "F1000000-1111-2222-3333-444444444444")!, + type: .broadcast, + senderID: "echo-device", + senderRole: "Echo", + parentID: "root", + treeLevel: 1, + ttl: 4, + encrypted: false, + latitude: 33.0, + longitude: -117.0, + accuracy: 5.0, + transcript: "Checkpoint clear and route open." + ) + ) + viewModel.query = "route" + + XCTAssertEqual(viewModel.results.count, 1) + XCTAssertEqual(viewModel.results.first?.senderRole, "Echo") + XCTAssertEqual(viewModel.results.first?.type, .broadcast) + XCTAssertEqual(viewModel.totalMessageCount, 1) + } + + func testTabNavigationDefinesAllFourTabsInExpectedOrder() { + XCTAssertEqual(TacNetTab.allCases.count, 4) + XCTAssertEqual(TacNetTab.allCases.map(\.title), ["Main", "Tree View", "Data Flow", "Settings"]) + } + + @MainActor + func testSettingsViewModelRoleBasedVisibilityForOrganiserAndParticipant() { + let organiserContext = makeRoleClaimContextForSettings( + localDeviceID: "organiser-device", + createdBy: "organiser-device", + claims: ["alpha": "organiser-device"] + ) + let organiserViewModel = SettingsViewModel(roleClaimService: organiserContext.roleService) + + XCTAssertTrue(organiserViewModel.showsOrganiserControls) + XCTAssertTrue(organiserViewModel.isEditTreeButtonVisible) + XCTAssertFalse(organiserViewModel.isEditTreeButtonDisabled) + + let participantContext = makeRoleClaimContextForSettings( + localDeviceID: "participant-device", + createdBy: "organiser-device", + claims: ["alpha": "participant-device"] + ) + let participantViewModel = SettingsViewModel(roleClaimService: participantContext.roleService) + + XCTAssertFalse(participantViewModel.showsOrganiserControls) + XCTAssertFalse(participantViewModel.isEditTreeButtonVisible) + XCTAssertTrue(participantViewModel.isEditTreeButtonDisabled) + } + + @MainActor + func testSettingsViewModelReleaseRoleBroadcastsReleaseAndClearsClaim() throws { + let context = makeRoleClaimContextForSettings( + localDeviceID: "local-device", + createdBy: "organiser-device", + claims: ["alpha": "local-device"] + ) + let viewModel = SettingsViewModel(roleClaimService: context.roleService) + + XCTAssertTrue(viewModel.canReleaseRole) + XCTAssertTrue(viewModel.releaseRole()) + XCTAssertFalse(viewModel.canReleaseRole) + XCTAssertEqual(viewModel.statusMessage, "Released alpha.") + + XCTAssertNil(claimedByValue(nodeID: "alpha", in: context.syncService.localConfig)) + XCTAssertEqual(context.transport.sentPackets.count, 1) + let releaseMessage = try decodeMessage(from: context.transport.sentPackets[0].data) + XCTAssertEqual(releaseMessage.type, .release) + XCTAssertEqual(releaseMessage.payload.claimedNodeID, "alpha") + } + + @MainActor + func testSettingsViewModelOrganiserCanAddRenameAndRemoveNodesFromSettings() throws { + let context = makeRoleClaimContextForSettings( + localDeviceID: "organiser-device", + createdBy: "organiser-device" + ) + let viewModel = SettingsViewModel(roleClaimService: context.roleService) + + viewModel.selectedNodeID = "root" + viewModel.newChildLabelDraft = "Delta" + XCTAssertTrue(viewModel.addChildToSelectedNode()) + + let createdNodeID = try XCTUnwrap(viewModel.selectedNodeID) + XCTAssertEqual(findNode(nodeID: createdNodeID, in: try XCTUnwrap(context.syncService.localConfig).tree)?.label, "Delta") + + viewModel.renameDraft = "Delta Prime" + XCTAssertTrue(viewModel.renameSelectedNode()) + XCTAssertEqual(findNode(nodeID: createdNodeID, in: try XCTUnwrap(context.syncService.localConfig).tree)?.label, "Delta Prime") + + XCTAssertTrue(viewModel.removeSelectedNode()) + XCTAssertNil(findNode(nodeID: createdNodeID, in: try XCTUnwrap(context.syncService.localConfig).tree)) + + let sentTypes = try context.transport.sentPackets.map { try decodeMessage(from: $0.data).type } + XCTAssertEqual(sentTypes, [.treeUpdate, .treeUpdate, .treeUpdate]) + } + + @MainActor + func testSettingsViewModelPromoteFromSettingsTransfersOrganiserPrivileges() throws { + let context = makeRoleClaimContextForSettings( + localDeviceID: "old-organiser-device", + createdBy: "old-organiser-device", + claims: ["alpha": "new-organiser-device"] + ) + let viewModel = SettingsViewModel(roleClaimService: context.roleService) + + viewModel.promoteTargetNodeID = "alpha" + XCTAssertTrue(viewModel.promoteSelectedNode()) + XCTAssertEqual(context.syncService.localConfig?.createdBy, "new-organiser-device") + XCTAssertFalse(viewModel.showsOrganiserControls) + + XCTAssertEqual(context.transport.sentPackets.count, 1) + let promoteMessage = try decodeMessage(from: context.transport.sentPackets[0].data) + XCTAssertEqual(promoteMessage.type, .promote) + XCTAssertEqual(promoteMessage.payload.targetNodeID, "alpha") + } + + @MainActor + func testSettingsTreeEditorDragDropReparentBroadcastsTreeUpdate() throws { + let context = makeRoleClaimContextForSettings( + localDeviceID: "organiser-device", + createdBy: "organiser-device" + ) + let viewModel = SettingsViewModel(roleClaimService: context.roleService) + + XCTAssertTrue(viewModel.handleNodeDrop(draggedNodeID: "alpha-1", onto: "bravo")) + + let updatedConfig = try XCTUnwrap(context.syncService.localConfig) + XCTAssertEqual(TreeHelpers.parent(of: "alpha-1", in: updatedConfig.tree)?.id, "bravo") + + XCTAssertEqual(context.transport.sentPackets.count, 1) + let treeUpdate = try decodeMessage(from: context.transport.sentPackets[0].data) + XCTAssertEqual(treeUpdate.type, .treeUpdate) + let updatedTree = try XCTUnwrap(treeUpdate.payload.tree) + XCTAssertEqual(TreeHelpers.parent(of: "alpha-1", in: updatedTree)?.id, "bravo") + } + + @MainActor + func testSettingsTreeEditorDragDropReorderBroadcastsTreeUpdateAndPersistsAcrossRestart() throws { + let suiteName = "TacNetTests.NetworkConfigStore.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defaults.removePersistentDomain(forName: suiteName) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let networkConfigStore = NetworkConfigStore( + defaults: defaults, + storageKey: "TacNetTests.NetworkConfigStore.TreeOrder" + ) + + let transport = MockBluetoothMeshTransport() + let meshService = BluetoothMeshService( + transport: transport, + deduplicator: MessageDeduplicator(capacity: 1_000) + ) + let treeSyncService = TreeSyncService(meshService: meshService, configStore: networkConfigStore) + transport.emit( + .connectionStateChanged( + UUID(uuidString: "D0D0D0D0-0000-0000-0000-000000000002")!, + .connected + ) + ) + + var config = NetworkConfig( + networkName: "TacNet Settings", + networkID: UUID(uuidString: "DEADBEEF-CAFE-BABE-FADE-000000000002")!, + createdBy: "organiser-device", + pinHash: nil, + version: 1, + tree: makeFixtureTree() + ) + treeSyncService.setLocalConfig(config) + + let roleService = RoleClaimService( + meshService: meshService, + treeSyncService: treeSyncService, + localDeviceID: "organiser-device", + disconnectTimeout: 60 + ) + let viewModel = SettingsViewModel(roleClaimService: roleService) + + XCTAssertTrue(viewModel.handleNodeDrop(draggedNodeID: "charlie", onto: "alpha")) + + config = try XCTUnwrap(treeSyncService.localConfig) + XCTAssertEqual(TreeHelpers.children(of: "root", in: config.tree).map(\.id), ["charlie", "alpha", "bravo"]) + + XCTAssertEqual(transport.sentPackets.count, 1) + let treeUpdate = try decodeMessage(from: transport.sentPackets[0].data) + XCTAssertEqual(treeUpdate.type, .treeUpdate) + let updatedTree = try XCTUnwrap(treeUpdate.payload.tree) + XCTAssertEqual(TreeHelpers.children(of: "root", in: updatedTree).map(\.id), ["charlie", "alpha", "bravo"]) + + let restartedMesh = BluetoothMeshService( + transport: MockBluetoothMeshTransport(), + deduplicator: MessageDeduplicator(capacity: 1_000) + ) + let restartedTreeSyncService = TreeSyncService(meshService: restartedMesh, configStore: networkConfigStore) + let restartedConfig = try XCTUnwrap(restartedTreeSyncService.localConfig) + XCTAssertEqual(TreeHelpers.children(of: "root", in: restartedConfig.tree).map(\.id), ["charlie", "alpha", "bravo"]) + } + + @MainActor + func testDataFlowViewModelIncomingSectionListsReceivedMessagesWithMetadata() { + let viewModel = DataFlowViewModel() + let olderTimestamp = Date(timeIntervalSince1970: 1_700_800_010) + let newerTimestamp = Date(timeIntervalSince1970: 1_700_800_050) + + let olderMessage = Message.make( + id: UUID(uuidString: "AAAA0000-1111-2222-3333-444444444444")!, + type: .claim, + senderID: "alpha-device", + senderRole: "Alpha Lead", + parentID: "root", + treeLevel: 1, + ttl: 4, + encrypted: false, + latitude: nil, + longitude: nil, + accuracy: nil, + timestamp: olderTimestamp + ) + let newerMessage = Message.make( + id: UUID(uuidString: "BBBB0000-1111-2222-3333-444444444444")!, + type: .compaction, + senderID: "bravo-1", + senderRole: "Bravo 1", + parentID: "bravo", + treeLevel: 2, + ttl: 4, + encrypted: false, + latitude: nil, + longitude: nil, + accuracy: nil, + summary: "Bravo compaction summary", + timestamp: newerTimestamp + ) + + viewModel.handleIncomingMessage(olderMessage) + viewModel.handleIncomingMessage(newerMessage) + + XCTAssertEqual(viewModel.incomingEntries.count, 2) + XCTAssertEqual(viewModel.incomingEntries.map(\.messageID), [newerMessage.id, olderMessage.id]) + XCTAssertEqual(viewModel.incomingEntries.map(\.senderRole), ["Bravo 1", "Alpha Lead"]) + XCTAssertEqual(viewModel.incomingEntries.map(\.senderID), ["bravo-1", "alpha-device"]) + XCTAssertEqual(viewModel.incomingEntries.map(\.typeLabel), ["COMPACTION", "CLAIM"]) + XCTAssertEqual(viewModel.incomingEntries.map(\.timestamp), [newerTimestamp, olderTimestamp]) + } + + @MainActor + func testDataFlowViewModelProcessingSectionShowsStatusAndAIMetricsWithinOneSecond() async throws { + let viewModel = DataFlowViewModel() + let summarizer = MockTacticalSummarizer( + outputs: ["Alpha summary output with casualty and route update."], + delayNanoseconds: 220_000_000 + ) + let engine = makeCompactionEngine( + localNodeID: "alpha", + localDeviceID: "alpha-device", + localRole: "alpha-lead", + tree: makeFixtureTree(), + summarizer: summarizer, + configuration: .init(timeWindow: 30, messageCountThreshold: 1, defaultTTL: 8) + ) + + await engine.setProcessingObserver { metrics in + Task { @MainActor in + viewModel.handleProcessingMetrics(metrics) + } + } + + let enqueueTask = Task { + await engine.enqueueChildTranscript( + "Alpha-1 reports two hostiles near checkpoint east and route blocked by debris.", + from: "alpha-1" + ) + } + + let compactingObserved = await waitForCondition(timeout: 1.0) { + await MainActor.run { + viewModel.processing.status == .compacting && + viewModel.processing.triggerReason == .messageCount + } + } + XCTAssertTrue(compactingObserved, "Expected compacting status within one second") + + await enqueueTask.value + + let idleObserved = await waitForCondition(timeout: 1.0) { + await MainActor.run { + viewModel.processing.status == .idle && + viewModel.processing.latencyMilliseconds != nil + } + } + XCTAssertTrue(idleObserved, "Expected idle status with latency metrics within one second") + + XCTAssertEqual(viewModel.processing.triggerReason, .messageCount) + XCTAssertGreaterThan(viewModel.processing.inputTokenCount, 0) + XCTAssertGreaterThan(viewModel.processing.outputTokenCount, 0) + XCTAssertNotNil(viewModel.processing.compressionRatio) + XCTAssertGreaterThan(viewModel.processing.latencyMilliseconds ?? 0, 0) + } + + @MainActor + func testDataFlowViewModelOutgoingSectionListsEveryEmittedCompaction() async throws { + let viewModel = DataFlowViewModel() + let summarizer = MockTacticalSummarizer( + outputs: [ + "First emitted compaction output.", + "Second emitted compaction output." + ] + ) + let engine = makeCompactionEngine( + localNodeID: "alpha", + localDeviceID: "alpha-device", + localRole: "alpha-lead", + tree: makeFixtureTree(), + summarizer: summarizer, + configuration: .init(timeWindow: 30, messageCountThreshold: 1, defaultTTL: 8) + ) + + await engine.setCompactionEmissionObserver { emission in + Task { @MainActor in + viewModel.handleOutgoingCompaction(emission) + } + } + + await engine.enqueueChildTranscript("Alpha-1 contact at grid seven.", from: "alpha-1") + await engine.enqueueChildTranscript("Alpha-2 route secure at grid eight.", from: "alpha-2") + + let outgoingObserved = await waitForCondition(timeout: 1.0) { + await MainActor.run { + viewModel.outgoingEntries.count == 2 + } + } + XCTAssertTrue(outgoingObserved, "Expected outgoing compaction entries within one second") + + XCTAssertEqual(viewModel.outgoingEntries.count, 2) + XCTAssertEqual(viewModel.outgoingEntries[0].destinationNodeID, "root") + XCTAssertEqual(viewModel.outgoingEntries[0].sourceNodeIDs, ["alpha-2"]) + XCTAssertEqual(viewModel.outgoingEntries[0].outputText, "Second emitted compaction output.") + XCTAssertEqual(viewModel.outgoingEntries[1].destinationNodeID, "root") + XCTAssertEqual(viewModel.outgoingEntries[1].sourceNodeIDs, ["alpha-1"]) + XCTAssertEqual(viewModel.outgoingEntries[1].outputText, "First emitted compaction output.") + } + + @MainActor + func testTreeViewModelBuildsHierarchyAndShowsClaimedByLabels() { + let transport = MockBluetoothMeshTransport() + let meshService = BluetoothMeshService(transport: transport, deduplicator: MessageDeduplicator(capacity: 1_000)) + let treeSync = TreeSyncService(meshService: meshService) + + var config = NetworkConfig( + networkName: "TacNet Tree", + networkID: UUID(uuidString: "12345678-90AB-CDEF-1234-567890ABCDEF")!, + createdBy: "organiser-device", + pinHash: nil, + version: 1, + tree: makeFixtureTree() + ) + _ = mutateClaim(nodeID: "alpha", claimedBy: "alpha-device", in: &config.tree) + treeSync.setLocalConfig(config) + + let roleClaimService = RoleClaimService( + meshService: meshService, + treeSyncService: treeSync, + localDeviceID: "local-device", + disconnectTimeout: 60 + ) + + let viewModel = TreeViewModel( + roleClaimService: roleClaimService, + localDeviceID: "local-device", + nowProvider: { Date(timeIntervalSince1970: 1_700_500_000) } + ) + + let alphaRow = viewModel.rows.first(where: { $0.id == "alpha" }) + let bravoRow = viewModel.rows.first(where: { $0.id == "bravo" }) + let alphaOneRow = viewModel.rows.first(where: { $0.id == "alpha-1" }) + + XCTAssertEqual(alphaRow?.depth, 1) + XCTAssertEqual(alphaOneRow?.depth, 2) + XCTAssertEqual(alphaRow?.claimedByText, "claimed_by: alpha-device") + XCTAssertEqual(bravoRow?.claimedByText, "claimed_by: Available") + } + + @MainActor + func testTreeViewModelStatusTransitionsUseThirtyAndSixtySecondThresholds() { + let transport = MockBluetoothMeshTransport() + let meshService = BluetoothMeshService(transport: transport, deduplicator: MessageDeduplicator(capacity: 1_000)) + let treeSync = TreeSyncService(meshService: meshService) + treeSync.setLocalConfig( + NetworkConfig( + networkName: "TacNet Status", + networkID: UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-000000000001")!, + createdBy: "organiser-device", + pinHash: nil, + version: 1, + tree: makeFixtureTree() + ) + ) + + let roleClaimService = RoleClaimService( + meshService: meshService, + treeSyncService: treeSync, + localDeviceID: "local-device", + disconnectTimeout: 60 + ) + + let baseTime = Date(timeIntervalSince1970: 1_700_600_000) + let viewModel = TreeViewModel( + roleClaimService: roleClaimService, + localDeviceID: "local-device", + nowProvider: { baseTime } + ) + + XCTAssertEqual(viewModel.status(for: "alpha", now: baseTime.addingTimeInterval(30)), .active) + XCTAssertEqual(viewModel.status(for: "alpha", now: baseTime.addingTimeInterval(31)), .idle) + XCTAssertEqual(viewModel.status(for: "alpha", now: baseTime.addingTimeInterval(60)), .idle) + XCTAssertEqual(viewModel.status(for: "alpha", now: baseTime.addingTimeInterval(61)), .disconnected) + } + + @MainActor + func testTreeViewModelCompactionSummaryIsTruncatedAndExpandsOnToggle() { + let transport = MockBluetoothMeshTransport() + let meshService = BluetoothMeshService(transport: transport, deduplicator: MessageDeduplicator(capacity: 1_000)) + let treeSync = TreeSyncService(meshService: meshService) + treeSync.setLocalConfig( + NetworkConfig( + networkName: "TacNet Compaction", + networkID: UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-000000000002")!, + createdBy: "organiser-device", + pinHash: nil, + version: 1, + tree: makeFixtureTree() + ) + ) + + let roleClaimService = RoleClaimService( + meshService: meshService, + treeSyncService: treeSync, + localDeviceID: "local-device", + disconnectTimeout: 60 + ) + + let viewModel = TreeViewModel( + roleClaimService: roleClaimService, + localDeviceID: "local-device", + nowProvider: { Date(timeIntervalSince1970: 1_700_700_000) } + ) + + let longSummary = "Alpha child reports two hostiles near ridge line, one casualty, route blocked by debris, and requests urgent support from Bravo squad immediately." + let compactionMessage = Message.make( + id: UUID(uuidString: "AAAA1111-BBBB-CCCC-DDDD-EEEEEEEEEEEE")!, + type: .compaction, + senderID: "alpha-1", + senderRole: "Alpha 1", + parentID: "alpha", + treeLevel: 2, + ttl: 4, + encrypted: false, + latitude: nil, + longitude: nil, + accuracy: nil, + summary: longSummary, + timestamp: Date(timeIntervalSince1970: 1_700_700_010) + ) + + viewModel.handleIncomingMessage(compactionMessage) + + let alphaBeforeExpand = viewModel.rows.first(where: { $0.id == "alpha" }) + XCTAssertNotNil(alphaBeforeExpand?.compactionDisplayText) + XCTAssertNotEqual(alphaBeforeExpand?.compactionDisplayText, longSummary) + XCTAssertTrue(alphaBeforeExpand?.compactionDisplayText?.hasSuffix("…") == true) + + viewModel.toggleCompactionExpansion(for: "alpha") + let alphaAfterExpand = viewModel.rows.first(where: { $0.id == "alpha" }) + XCTAssertEqual(alphaAfterExpand?.compactionDisplayText, longSummary) + XCTAssertTrue(alphaAfterExpand?.isCompactionExpanded == true) + } + + func testAudioServiceAcceptsValidPCM16kMono16BitAndForwardsTranscript() async throws { + let clip = makeAlternatingPCMClip(sampleCount: 400, amplitude: 1_200) + let capturer = MockAudioCapturer(clips: [clip]) + let transcriber = MockCactusTranscriber(results: ["Alpha contact east"]) + let transcriptConsumer = MockTranscriptConsumer() + let audioService = AudioService( + capturer: capturer, + transcriber: transcriber, + transcriptConsumer: transcriptConsumer, + maxRecordingDuration: 60 + ) + + try await audioService.pttPressed() + let queuedSequence = try await audioService.pttReleased() + XCTAssertEqual(queuedSequence, 0) + + await audioService.waitForIdle() + + let history = await audioService.transcriptHistory + XCTAssertEqual(history.count, 1) + XCTAssertEqual(history.first?.sequence, 0) + XCTAssertEqual(history.first?.transcript, "Alpha contact east") + + let transcribedInputs = await transcriber.receivedPCMInputs() + XCTAssertEqual(transcribedInputs, [clip.data]) + XCTAssertTrue(clip.isPCM16kMono16Bit) + + let consumed = await transcriptConsumer.received() + XCTAssertEqual(consumed.count, 1) + XCTAssertEqual(consumed.first?.transcript, "Alpha contact east") + } + + func testAudioServiceSkipsEmptyAndSilenceAudioWithoutCreatingTranscript() async throws { + let emptyClip = makePCMClip(samples: []) + let silenceClip = makePCMClip(samples: Array(repeating: 0, count: 2_000)) + let capturer = MockAudioCapturer(clips: [emptyClip, silenceClip]) + let transcriber = MockCactusTranscriber(results: ["should-not-be-used"]) + let transcriptConsumer = MockTranscriptConsumer() + let audioService = AudioService( + capturer: capturer, + transcriber: transcriber, + transcriptConsumer: transcriptConsumer, + maxRecordingDuration: 60 + ) + + try await audioService.pttPressed() + let firstResult = try await audioService.pttReleased() + XCTAssertNil(firstResult, "Zero-length audio should not queue a transcript") + + try await audioService.pttPressed() + let secondResult = try await audioService.pttReleased() + XCTAssertNil(secondResult, "Silence-only audio should not queue a transcript") + + await audioService.waitForIdle() + + let history = await audioService.transcriptHistory + let transcribedInputs = await transcriber.receivedPCMInputs() + let consumed = await transcriptConsumer.received() + XCTAssertTrue(history.isEmpty) + XCTAssertTrue(transcribedInputs.isEmpty) + XCTAssertTrue(consumed.isEmpty) + } + + func testAudioServiceSerializesRapidSequentialPTTWithoutCorruption() async throws { + let clipA = makeAlternatingPCMClip(sampleCount: 360, amplitude: 1_000) + let clipB = makeAlternatingPCMClip(sampleCount: 380, amplitude: 2_000) + let clipC = makeAlternatingPCMClip(sampleCount: 400, amplitude: 3_000) + let capturer = MockAudioCapturer(clips: [clipA, clipB, clipC]) + let transcriber = MockCactusTranscriber( + results: ["first", "second", "third"], + delayNanoseconds: 40_000_000 + ) + let transcriptConsumer = MockTranscriptConsumer() + let audioService = AudioService( + capturer: capturer, + transcriber: transcriber, + transcriptConsumer: transcriptConsumer, + maxRecordingDuration: 60 + ) + + try await audioService.pttPressed() + _ = try await audioService.pttReleased() + try await audioService.pttPressed() + _ = try await audioService.pttReleased() + try await audioService.pttPressed() + _ = try await audioService.pttReleased() + + await audioService.waitForIdle() + + let history = await audioService.transcriptHistory + XCTAssertEqual(history.map(\.sequence), [0, 1, 2]) + XCTAssertEqual(history.map(\.transcript), ["first", "second", "third"]) + + let transcribedInputs = await transcriber.receivedPCMInputs() + XCTAssertEqual(transcribedInputs, [clipA.data, clipB.data, clipC.data], "Each rapid press should transcribe its own clip in order without data corruption") + + let consumed = await transcriptConsumer.received() + XCTAssertEqual(consumed.map(\.sequence), [0, 1, 2]) + XCTAssertEqual(consumed.map(\.transcript), ["first", "second", "third"]) + } + + func testAudioServiceCapsVeryLongAudioBeforeTranscription() async throws { + let ninetySecondSampleCount = 16_000 * 90 + let longClip = makeAlternatingPCMClip(sampleCount: ninetySecondSampleCount, amplitude: 1_500) + let capturer = MockAudioCapturer(clips: [longClip]) + let transcriber = MockCactusTranscriber(results: ["long clip transcript"]) + let transcriptConsumer = MockTranscriptConsumer() + let audioService = AudioService( + capturer: capturer, + transcriber: transcriber, + transcriptConsumer: transcriptConsumer, + maxRecordingDuration: 60 + ) + + try await audioService.pttPressed() + _ = try await audioService.pttReleased() + await audioService.waitForIdle() + + let transcribedInputs = await transcriber.receivedPCMInputs() + let firstInput = try XCTUnwrap(transcribedInputs.first) + XCTAssertEqual(firstInput.count, 16_000 * 60 * 2, "Audio should be capped to 60 seconds at 16kHz mono 16-bit") + let history = await audioService.transcriptHistory + XCTAssertEqual(history.count, 1) + } + + func testCompactionEngineTimeWindowTriggerFiresAfterConfiguredWindow() async throws { + let tree = makeFixtureTree() + let summarizer = MockTacticalSummarizer(outputs: [ + "Grid 12 east hostile movement, Alpha holding." + ]) + let engine = makeCompactionEngine( + localNodeID: "alpha", + localDeviceID: "alpha-device", + localRole: "alpha-lead", + tree: tree, + summarizer: summarizer, + configuration: .init(timeWindow: 0.05, messageCountThreshold: 5, defaultTTL: 6) + ) + + await engine.enqueueChildTranscript( + "Alpha-1 reports hostile movement near grid 12 east; one hostile observed; status holding.", + from: "alpha-1" + ) + + let triggered = await waitForCondition(timeout: 1.0) { + let emissions = await engine.emittedCompactions() + return emissions.count == 1 + } + XCTAssertTrue(triggered, "Expected time-window trigger to emit a compaction") + + let emissions = await engine.emittedCompactions() + let emission = try XCTUnwrap(emissions.first) + XCTAssertEqual(emission.triggerReason, .timeWindow) + XCTAssertEqual(emission.sourceMessageCount, 1) + XCTAssertEqual(emission.message.type, .compaction) + XCTAssertEqual(emission.message.parentID, "root") + XCTAssertEqual(emission.message.ttl, 6) + } + + func testCompactionEngineCountThresholdTriggersAtBoundary() async throws { + let summarizer = MockTacticalSummarizer(outputs: ["Alpha sector summary with threat and status."]) + let engine = makeCompactionEngine( + localNodeID: "alpha", + localDeviceID: "alpha-device", + localRole: "alpha-lead", + tree: makeFixtureTree(), + summarizer: summarizer, + configuration: .init(timeWindow: 30, messageCountThreshold: 3, defaultTTL: 8) + ) + + await engine.enqueueChildTranscript("Alpha-1 movement at grid A1", from: "alpha-1") + await engine.enqueueChildTranscript("Alpha-2 no visual on threat", from: "alpha-2") + let emissionsBeforeThreshold = await engine.emittedCompactions() + XCTAssertTrue(emissionsBeforeThreshold.isEmpty) + + await engine.enqueueChildTranscript("Alpha-1 status green", from: "alpha-1") + let emissionsAfterThreshold = await engine.emittedCompactions() + let emission = try XCTUnwrap(emissionsAfterThreshold.first) + XCTAssertEqual(emission.triggerReason, .messageCount) + XCTAssertEqual(emission.sourceMessageCount, 3) + } + + func testCompactionEnginePriorityKeywordsTriggerImmediatelyCaseInsensitive() async throws { + let summarizer = MockTacticalSummarizer(outputs: ["Emergency contact summary."]) + let engine = makeCompactionEngine( + localNodeID: "alpha", + localDeviceID: "alpha-device", + localRole: "alpha-lead", + tree: makeFixtureTree(), + summarizer: summarizer, + configuration: .init(timeWindow: 30, messageCountThreshold: 10, defaultTTL: 8) + ) + + await engine.enqueueChildTranscript("Patrol reports EMERGENCY near checkpoint bravo.", from: "alpha-1") + + let emissions = await engine.emittedCompactions() + let emission = try XCTUnwrap(emissions.first) + XCTAssertEqual(emission.triggerReason, .priorityKeyword) + XCTAssertEqual(emission.sourceMessageCount, 1) + } + + func testCompactionEnginePriorityKeywordPositionInvariantAndRejectsSubstrings() async throws { + let summarizer = MockTacticalSummarizer(outputs: [ + "Start keyword summary", + "Middle keyword summary", + "End keyword summary" + ]) + let engine = makeCompactionEngine( + localNodeID: "alpha", + localDeviceID: "alpha-device", + localRole: "alpha-lead", + tree: makeFixtureTree(), + summarizer: summarizer, + configuration: .init(timeWindow: 30, messageCountThreshold: 10, defaultTTL: 8) + ) + + await engine.enqueueChildTranscript("Contact observed at north ridge.", from: "alpha-1") + await engine.enqueueChildTranscript("Alpha-2 reports casualty at waypoint 3.", from: "alpha-2") + await engine.enqueueChildTranscript("Unit remains stable until emergency", from: "alpha-1") + let keywordEmissions = await engine.emittedCompactions() + XCTAssertEqual(keywordEmissions.count, 3, "Keyword should trigger at start/middle/end") + + await engine.enqueueChildTranscript("Alpha team contacted support and moved out.", from: "alpha-1") + await engine.enqueueChildTranscript("Subcontract convoy passing through corridor.", from: "alpha-2") + await engine.enqueueChildTranscript("Make contact lens appointment after patrol.", from: "alpha-1") + await engine.enqueueChildTranscript("Emergency exit sign reported in admin building.", from: "alpha-2") + await engine.enqueueChildTranscript("Casualties expected if weather worsens.", from: "alpha-1") + let finalEmissions = await engine.emittedCompactions() + XCTAssertEqual( + finalEmissions.count, + 3, + "Substring and known benign phrase matches must not trigger immediate compaction" + ) + } + + func testCompactionEngineSummaryIsUnderThirtyWordsWithoutFillerAndKeepsCriticalInfo() async throws { + let summarizer = MockTacticalSummarizer(outputs: [ + """ + Uh um copy that roger say again over. Grid nine east has two hostiles and one casualty. \ + Alpha squad status stable while Bravo secures extraction route immediately. + """ + ]) + let engine = makeCompactionEngine( + localNodeID: "alpha", + localDeviceID: "alpha-device", + localRole: "alpha-lead", + tree: makeFixtureTree(), + summarizer: summarizer, + configuration: .init(timeWindow: 30, messageCountThreshold: 1, defaultTTL: 8) + ) + + await engine.enqueueChildTranscript( + "Grid nine east reports two hostiles, one casualty, Alpha stable, Bravo securing extraction.", + from: "alpha-1" + ) + + let emissions = await engine.emittedCompactions() + let emission = try XCTUnwrap(emissions.first) + let summary = try XCTUnwrap(emission.message.payload.summary) + XCTAssertLessThanOrEqual(summary.split(whereSeparator: \.isWhitespace).count, 30) + + let lowered = summary.lowercased() + ["uh", "um", "copy that", "roger", "say again", "over"].forEach { filler in + XCTAssertFalse(lowered.contains(filler), "Summary should remove filler phrase: \(filler)") + } + XCTAssertTrue(lowered.contains("grid")) + XCTAssertTrue(lowered.contains("hostiles")) + XCTAssertTrue(lowered.contains("status")) + } + + func testCompactionEngineSingleMessageCompactionIsValid() async throws { + let summarizer = MockTacticalSummarizer(outputs: [ + "Grid seven north contact suppressed, one casualty stable, Alpha holding perimeter." + ]) + let engine = makeCompactionEngine( + localNodeID: "alpha", + localDeviceID: "alpha-device", + localRole: "alpha-lead", + tree: makeFixtureTree(), + summarizer: summarizer, + configuration: .init(timeWindow: 30, messageCountThreshold: 1, defaultTTL: 8) + ) + + await engine.enqueueChildTranscript("Alpha-1 contact grid seven north, casualty stable.", from: "alpha-1") + + let emissions = await engine.emittedCompactions() + let emission = try XCTUnwrap(emissions.first) + XCTAssertEqual(emission.sourceMessageCount, 1) + XCTAssertLessThanOrEqual( + (emission.message.payload.summary ?? "").split(whereSeparator: \.isWhitespace).count, + 30 + ) + } + + func testCompactionEngineHandlesTwentyMessagesWithoutDroppingContext() async throws { + let summarizer = MockTacticalSummarizer(outputs: ["Twenty-message tactical compaction summary."]) + let engine = makeCompactionEngine( + localNodeID: "alpha", + localDeviceID: "alpha-device", + localRole: "alpha-lead", + tree: makeFixtureTree(), + summarizer: summarizer, + configuration: .init(timeWindow: 30, messageCountThreshold: 20, defaultTTL: 8) + ) + + for index in 1...20 { + await engine.enqueueChildTranscript("Report \(index): status update at grid \(index).", from: "alpha-1") + } + + let emissions = await engine.emittedCompactions() + let emission = try XCTUnwrap(emissions.first) + XCTAssertEqual(emission.sourceMessageCount, 20) + + let invocations = await summarizer.invocations() + let prompt = try XCTUnwrap(invocations.first?.userPrompt.lowercased()) + XCTAssertTrue(prompt.contains("report 1")) + XCTAssertTrue(prompt.contains("report 20")) + } + + func testCompactionEngineUsesTacticalSummarizerPromptRequirements() async throws { + let summarizer = MockTacticalSummarizer(outputs: ["Prompt verification summary."]) + let engine = makeCompactionEngine( + localNodeID: "alpha", + localDeviceID: "alpha-device", + localRole: "alpha-lead", + tree: makeFixtureTree(), + summarizer: summarizer, + configuration: .init(timeWindow: 30, messageCountThreshold: 1, defaultTTL: 8) + ) + + await engine.enqueueChildTranscript("Alpha-1 reports contact on east ridge.", from: "alpha-1") + _ = await engine.emittedCompactions() + + let invocations = await summarizer.invocations() + let invocation = try XCTUnwrap(invocations.first) + let prompt = invocation.systemPrompt.lowercased() + XCTAssertTrue(prompt.contains("preserve")) + XCTAssertTrue(prompt.contains("location")) + XCTAssertTrue(prompt.contains("threat")) + XCTAssertTrue(prompt.contains("status")) + XCTAssertTrue(prompt.contains("remove filler")) + XCTAssertTrue(prompt.contains("under 30 words")) + } + + func testCompactionEngineEmittedCompactionRoutesOnlyToParent() async throws { + let tree = makeFixtureTree() + let summarizer = MockTacticalSummarizer(outputs: ["Alpha contact summary for parent."]) + let engine = makeCompactionEngine( + localNodeID: "alpha", + localDeviceID: "alpha-device", + localRole: "alpha-lead", + tree: tree, + summarizer: summarizer, + configuration: .init(timeWindow: 30, messageCountThreshold: 1, defaultTTL: 8) + ) + let router = MessageRouter() + + await engine.enqueueChildTranscript("Alpha-1 reports contact east.", from: "alpha-1") + let emissions = await engine.emittedCompactions() + let message = try XCTUnwrap(emissions.first?.message) + + XCTAssertTrue(router.shouldDisplay(message, for: "root", in: tree)) + XCTAssertFalse(router.shouldDisplay(message, for: "alpha-1", in: tree)) + XCTAssertFalse(router.shouldDisplay(message, for: "alpha-2", in: tree)) + XCTAssertFalse(router.shouldDisplay(message, for: "bravo", in: tree)) + XCTAssertFalse(router.shouldDisplay(message, for: "charlie-1", in: tree)) + } + + func testCompactionEngineRootProducesSitrepFromL1CompactionsOnly() async throws { + let tree = makeFixtureTree() + let summarizer = MockTacticalSummarizer(outputs: ["Sitrep: contact east, one casualty, alpha holding, bravo securing route."]) + let engine = makeCompactionEngine( + localNodeID: "root", + localDeviceID: "root-device", + localRole: "commander", + tree: tree, + summarizer: summarizer, + configuration: .init(timeWindow: 30, messageCountThreshold: 2, defaultTTL: 8) + ) + + await engine.enqueueL1CompactionSummary("Should ignore non-L1 source.", from: "alpha-1") + let ignoredSitrep = await engine.latestSITREP() + XCTAssertNil(ignoredSitrep) + + await engine.enqueueL1CompactionSummary("Alpha engagement east, one injured, status holding.", from: "alpha") + await engine.enqueueL1CompactionSummary("Bravo route secure, status green and moving.", from: "bravo") + + let latestSitrep = await engine.latestSITREP() + let sitrep = try XCTUnwrap(latestSitrep) + XCTAssertEqual(sitrep.triggerReason, .messageCount) + XCTAssertEqual(sitrep.sourceMessageCount, 2) + XCTAssertFalse(sitrep.text.isEmpty) + let rootEmissions = await engine.emittedCompactions() + XCTAssertTrue(rootEmissions.isEmpty, "Root should produce SITREP, not upward compaction messages") + } + + // VAL-CROSS-001 + @MainActor + func testCrossAreaFirstTimeJourneyDownloadInitJoinPTTAndTranscriptDeliveryWithinFiveSeconds() async throws { + let startedAt = Date() + + let sandbox = try makeModelDownloadSandbox() + defer { sandbox.cleanup() } + + let temporaryModelFile = try makeTemporaryModelFile(in: sandbox.baseDirectory) + let downloadService = makeModelDownloadService( + sandbox: sandbox, + downloader: MockURLSessionDownloadClient( + scriptedResponses: [ + .init( + progressEvents: [(100, 1_000), (500, 1_000), (1_000, 1_000)], + result: .success(temporaryModelFile) + ) + ] + ), + availableStorageBytes: 20_000_000_000 + ) + + _ = try await downloadService.ensureModelAvailable() + let initializer = CactusModelInitializationService( + downloadService: downloadService, + initFunction: { _, _, _ in + UnsafeMutableRawPointer(bitPattern: 0xBEEF)! + }, + destroyFunction: { _ in } + ) + _ = try await initializer.initializeModel() + + let organiserPeerID = UUID(uuidString: "10101010-0000-0000-0000-000000000001")! + let networkID = UUID(uuidString: "10101010-0000-0000-0000-000000000002")! + + let organiserTransport = MockBluetoothMeshTransport() + let organiserMesh = BluetoothMeshService( + transport: organiserTransport, + deduplicator: MessageDeduplicator(capacity: 1_000) + ) + let organiserSync = TreeSyncService(meshService: organiserMesh) + + var publishedConfig = NetworkConfig( + networkName: "Cross First Time", + networkID: networkID, + createdBy: "organiser-device", + pinHash: nil, + version: 1, + tree: makeFixtureTree() + ) + publishedConfig = organiserSync.secureConfigForPublishing(publishedConfig) + organiserSync.setLocalConfig(publishedConfig) + + let participantTransport = MockBluetoothMeshTransport() + participantTransport.setTreeConfig(publishedConfig, for: organiserPeerID) + let participantMesh = BluetoothMeshService( + transport: participantTransport, + deduplicator: MessageDeduplicator(capacity: 1_000) + ) + let participantSync = TreeSyncService(meshService: participantMesh) + + let discoveredNetwork = DiscoveredNetwork( + peerID: organiserPeerID, + networkID: networkID, + networkName: "Cross First Time", + openSlotCount: publishedConfig.openSlotCount, + requiresPIN: false + ) + let joinedConfig = try await participantSync.join(network: discoveredNetwork, pin: nil) + XCTAssertEqual(joinedConfig.networkID, networkID) + + var routedConfig = joinedConfig + _ = mutateClaim(nodeID: "alpha", claimedBy: "local-device", in: &routedConfig.tree) + _ = mutateClaim(nodeID: "bravo", claimedBy: "peer-device", in: &routedConfig.tree) + + let senderTransport = MockBluetoothMeshTransport() + let senderMesh = BluetoothMeshService( + transport: senderTransport, + deduplicator: MessageDeduplicator(capacity: 1_000) + ) + let senderSync = TreeSyncService(meshService: senderMesh) + senderSync.setLocalConfig(routedConfig) + let senderRoleService = RoleClaimService( + meshService: senderMesh, + treeSyncService: senderSync, + localDeviceID: "local-device", + disconnectTimeout: 60 + ) + let senderAudio = AudioService( + capturer: MockAudioCapturer(clips: [makeAlternatingPCMClip(sampleCount: 360, amplitude: 1_200)]), + transcriber: MockCactusTranscriber( + results: ["Alpha says contact east"], + delayNanoseconds: 80_000_000 + ), + maxRecordingDuration: 60 + ) + let senderMainViewModel = MainViewModel( + meshService: senderMesh, + roleClaimService: senderRoleService, + localDeviceID: "local-device", + audioService: senderAudio + ) + + let receiverMesh = BluetoothMeshService( + transport: MockBluetoothMeshTransport(), + deduplicator: MessageDeduplicator(capacity: 1_000) + ) + let receiverSync = TreeSyncService(meshService: receiverMesh) + receiverSync.setLocalConfig(routedConfig) + let receiverRoleService = RoleClaimService( + meshService: receiverMesh, + treeSyncService: receiverSync, + localDeviceID: "peer-device", + disconnectTimeout: 60 + ) + let receiverMainViewModel = MainViewModel( + meshService: receiverMesh, + roleClaimService: receiverRoleService, + localDeviceID: "peer-device", + audioService: AudioService( + capturer: MockAudioCapturer(clips: []), + transcriber: MockCactusTranscriber(results: []), + maxRecordingDuration: 60 + ) + ) + + let connectedPeerID = UUID(uuidString: "10101010-0000-0000-0000-000000000099")! + senderTransport.emit(.connectionStateChanged(connectedPeerID, .connected)) + senderMainViewModel.handlePeerConnectionStateChanged(peerID: connectedPeerID, state: .connected) + + await senderMainViewModel.startPushToTalk() + await senderMainViewModel.stopPushToTalk() + + let outboundPacket = try XCTUnwrap(senderTransport.sentPackets.first) + let outboundMessage = try decodeMessage(from: outboundPacket.data) + receiverMainViewModel.handleIncomingMessage(outboundMessage) + + XCTAssertEqual(receiverMainViewModel.feedEntries.count, 1) + XCTAssertEqual(receiverMainViewModel.feedEntries.first?.text, "Alpha says contact east") + XCTAssertLessThanOrEqual(Date().timeIntervalSince(startedAt), 5.0) + } + + // VAL-CROSS-002 + func testCrossAreaFullCommunicationCycleLeafToRootSitrepAndNoGrandparentRawLeak() async throws { + let tree = makeFixtureTree() + let router = MessageRouter() + let leafBroadcast = router.makeBroadcastMessage( + transcript: "Alpha-1 contact at ridge line.", + senderID: "alpha-1-device", + senderNodeID: "alpha-1", + senderRole: "Alpha 1", + in: tree + ) + + XCTAssertTrue(router.shouldDisplay(leafBroadcast, for: "alpha", in: tree)) + XCTAssertTrue(router.shouldDisplay(leafBroadcast, for: "alpha-2", in: tree)) + XCTAssertFalse(router.shouldDisplay(leafBroadcast, for: "root", in: tree)) + + let parentEngine = makeCompactionEngine( + localNodeID: "alpha", + localDeviceID: "alpha-device", + localRole: "Alpha Lead", + tree: tree, + summarizer: MockTacticalSummarizer(outputs: ["Alpha summary: contact at ridge, team holding."]), + configuration: .init(timeWindow: 30, messageCountThreshold: 1, defaultTTL: 8) + ) + await parentEngine.enqueueChildTranscript( + leafBroadcast.payload.transcript ?? "", + from: "alpha-1" + ) + let parentCompactions = await parentEngine.emittedCompactions() + let parentCompaction = try XCTUnwrap(parentCompactions.first) + XCTAssertEqual(parentCompaction.message.type, .compaction) + XCTAssertEqual(parentCompaction.message.parentID, "root") + + let rootEngine = makeCompactionEngine( + localNodeID: "root", + localDeviceID: "root-device", + localRole: "Commander", + tree: tree, + summarizer: MockTacticalSummarizer(outputs: ["SITREP: Alpha contact ridge, holding position."]), + configuration: .init(timeWindow: 30, messageCountThreshold: 1, defaultTTL: 8) + ) + await rootEngine.enqueueL1CompactionSummary( + parentCompaction.message.payload.summary ?? "", + from: "alpha" + ) + let rootSitrep = await rootEngine.latestSITREP() + let sitrep = try XCTUnwrap(rootSitrep) + XCTAssertFalse(sitrep.text.isEmpty) + } + + // VAL-CROSS-003 + @MainActor + func testCrossAreaTreeRestructureMidOperationReparentUpdatesRouting() throws { + let transport = MockBluetoothMeshTransport() + let meshService = BluetoothMeshService( + transport: transport, + deduplicator: MessageDeduplicator(capacity: 1_000) + ) + let treeSyncService = TreeSyncService(meshService: meshService) + + var config = NetworkConfig( + networkName: "Cross Reparent", + networkID: UUID(uuidString: "30303030-0000-0000-0000-000000000001")!, + createdBy: "organiser-device", + pinHash: nil, + version: 1, + tree: makeFixtureTree() + ) + _ = mutateClaim(nodeID: "alpha-1", claimedBy: "alpha-1-device", in: &config.tree) + treeSyncService.setLocalConfig(config) + + let roleService = RoleClaimService( + meshService: meshService, + treeSyncService: treeSyncService, + localDeviceID: "organiser-device", + disconnectTimeout: 60 + ) + XCTAssertTrue(roleService.moveNode(nodeID: "alpha-1", newParentID: "bravo")) + + let updatedTree = try XCTUnwrap(treeSyncService.localConfig?.tree) + let router = MessageRouter() + let postMoveBroadcast = router.makeBroadcastMessage( + transcript: "Routed after reparent.", + senderID: "alpha-1-device", + senderNodeID: "alpha-1", + senderRole: "Alpha 1", + in: updatedTree + ) + + XCTAssertEqual(postMoveBroadcast.parentID, "bravo") + XCTAssertTrue(router.shouldDisplay(postMoveBroadcast, for: "bravo", in: updatedTree)) + XCTAssertFalse(router.shouldDisplay(postMoveBroadcast, for: "alpha", in: updatedTree)) + } + + // VAL-CROSS-004 + @MainActor + func testCrossAreaNodeFailureRecoveryAutoReparentResumesCompactionRouting() async throws { + let transport = MockBluetoothMeshTransport() + let meshService = BluetoothMeshService( + transport: transport, + deduplicator: MessageDeduplicator(capacity: 1_000) + ) + let treeSyncService = TreeSyncService(meshService: meshService, disconnectTimeout: 0.05) + + let forwardingPeer = UUID(uuidString: "40404040-0000-0000-0000-000000000001")! + let rootPeer = UUID(uuidString: "40404040-0000-0000-0000-000000000002")! + let alphaPeer = UUID(uuidString: "40404040-0000-0000-0000-000000000003")! + let bravoPeer = UUID(uuidString: "40404040-0000-0000-0000-000000000004")! + let charliePeer = UUID(uuidString: "40404040-0000-0000-0000-000000000005")! + + [forwardingPeer, rootPeer, alphaPeer, bravoPeer, charliePeer].forEach { + transport.emit(.connectionStateChanged($0, .connected)) + } + + let config = makeAutoReparentNetworkConfig( + networkID: UUID(uuidString: "40404040-0000-0000-0000-000000000006")!, + version: 50, + rootOwnerID: rootPeer.uuidString, + alphaOwnerID: alphaPeer.uuidString, + bravoOwnerID: bravoPeer.uuidString, + charlieOwnerID: charliePeer.uuidString + ) + treeSyncService.setLocalConfig(config) + + transport.emit(.connectionStateChanged(bravoPeer, .disconnected)) + treeSyncService.handlePeerStateChange(peerID: bravoPeer, state: .disconnected) + try await Task.sleep(nanoseconds: 250_000_000) + + let updatedTree = try XCTUnwrap(treeSyncService.localConfig?.tree) + XCTAssertEqual(TreeHelpers.parent(of: "charlie", in: updatedTree)?.id, "alpha") + + let routedMessage = MessageRouter().makeCompactionMessage( + summary: "Charlie resumed reporting after failover.", + senderID: "charlie-device", + senderNodeID: "charlie", + senderRole: "Charlie", + in: updatedTree + ) + XCTAssertEqual(routedMessage.parentID, "alpha") + } + + // VAL-CROSS-005 + @MainActor + func testCrossAreaOrganiserHandoverAllowsNewOrganiserEditAndPropagation() throws { + let oldTransport = MockBluetoothMeshTransport() + let oldMesh = BluetoothMeshService( + transport: oldTransport, + deduplicator: MessageDeduplicator(capacity: 1_000) + ) + let oldSync = TreeSyncService(meshService: oldMesh) + let forwardingPeer = UUID(uuidString: "50505050-0000-0000-0000-000000000001")! + oldTransport.emit(.connectionStateChanged(forwardingPeer, .connected)) + + var config = NetworkConfig( + networkName: "Cross Handover", + networkID: UUID(uuidString: "50505050-0000-0000-0000-000000000002")!, + createdBy: "old-organiser-device", + pinHash: nil, + version: 5, + tree: makeFixtureTree() + ) + _ = mutateClaim(nodeID: "alpha", claimedBy: "new-organiser-device", in: &config.tree) + oldSync.setLocalConfig(config) + + let oldRoleService = RoleClaimService( + meshService: oldMesh, + treeSyncService: oldSync, + localDeviceID: "old-organiser-device", + disconnectTimeout: 60 + ) + XCTAssertTrue(oldRoleService.promote(nodeID: "alpha")) + + let promoteMessage = try XCTUnwrap( + oldTransport.sentPackets + .compactMap { try? decodeMessage(from: $0.data) } + .first(where: { $0.type == .promote }) + ) + + let newTransport = MockBluetoothMeshTransport() + let newMesh = BluetoothMeshService( + transport: newTransport, + deduplicator: MessageDeduplicator(capacity: 1_000) + ) + let newSync = TreeSyncService(meshService: newMesh) + let propagationPeer = UUID(uuidString: "50505050-0000-0000-0000-000000000003")! + newTransport.emit(.connectionStateChanged(propagationPeer, .connected)) + newSync.setLocalConfig(config) + let newRoleService = RoleClaimService( + meshService: newMesh, + treeSyncService: newSync, + localDeviceID: "new-organiser-device", + disconnectTimeout: 60 + ) + + newRoleService.handleIncomingMessage(promoteMessage) + XCTAssertTrue(newRoleService.isOrganiser) + XCTAssertTrue(newRoleService.renameNode(nodeID: "alpha", newLabel: "Alpha Command")) + + let propagatedUpdate = try XCTUnwrap( + newTransport.sentPackets + .compactMap { try? decodeMessage(from: $0.data) } + .first(where: { $0.type == .treeUpdate }) + ) + oldRoleService.handleIncomingMessage(propagatedUpdate) + + let oldTree = try XCTUnwrap(oldSync.localConfig?.tree) + XCTAssertEqual(findNode(nodeID: "alpha", in: oldTree)?.label, "Alpha Command") + } + + // VAL-CROSS-006 + @MainActor + func testCrossAreaAfterActionReviewSearchReturnsBroadcastAndCompactionWithMetadata() { + let store = InMemoryAfterActionReviewStore() + let viewModel = AfterActionReviewViewModel(store: store) + + viewModel.record( + Message.make( + type: .broadcast, + senderID: "leaf-device", + senderRole: "Leaf", + parentID: "alpha", + treeLevel: 2, + ttl: 4, + encrypted: false, + latitude: 34.001, + longitude: -117.001, + accuracy: 3.1, + transcript: "Casualty reported near checkpoint." + ) + ) + viewModel.record( + Message.make( + type: .compaction, + senderID: "alpha-device", + senderRole: "Alpha Lead", + parentID: "root", + treeLevel: 1, + ttl: 4, + encrypted: false, + latitude: 34.002, + longitude: -117.002, + accuracy: 4.2, + summary: "Casualty stabilized and extraction requested." + ) + ) + + viewModel.query = "casualty" + XCTAssertEqual(viewModel.results.count, 2) + XCTAssertEqual(Set(viewModel.results.map(\.type)), [.broadcast, .compaction]) + XCTAssertTrue(viewModel.results.allSatisfy { !$0.senderRole.isEmpty }) + XCTAssertTrue(viewModel.results.allSatisfy { $0.timestamp.timeIntervalSince1970 > 0 }) + } + + // VAL-CROSS-007 + func testCrossAreaDemoScenarioSection14CompletesWithinTwoMinutes() async throws { + let start = Date() + let tree = makeFixtureTree() + + let alphaEngine = makeCompactionEngine( + localNodeID: "alpha", + localDeviceID: "alpha-device", + localRole: "Alpha Lead", + tree: tree, + summarizer: MockTacticalSummarizer(outputs: ["Alpha compaction: contact east, one casualty."]), + configuration: .init(timeWindow: 30, messageCountThreshold: 1, defaultTTL: 8) + ) + let charlieEngine = makeCompactionEngine( + localNodeID: "charlie", + localDeviceID: "charlie-device", + localRole: "Charlie Lead", + tree: tree, + summarizer: MockTacticalSummarizer(outputs: ["Charlie compaction: route secure, moving north."]), + configuration: .init(timeWindow: 30, messageCountThreshold: 1, defaultTTL: 8) + ) + let rootEngine = makeCompactionEngine( + localNodeID: "root", + localDeviceID: "root-device", + localRole: "Commander", + tree: tree, + summarizer: MockTacticalSummarizer(outputs: ["SITREP: Alpha contact/casualty, Bravo route secure."]), + configuration: .init(timeWindow: 30, messageCountThreshold: 2, defaultTTL: 8) + ) + + await alphaEngine.enqueueChildTranscript("Alpha-1: contact and casualty at east ridge.", from: "alpha-1") + await charlieEngine.enqueueChildTranscript("Charlie-1: route secure, advancing.", from: "charlie-1") + + let alphaCompactions = await alphaEngine.emittedCompactions() + let charlieCompactions = await charlieEngine.emittedCompactions() + let alphaCompaction = try XCTUnwrap(alphaCompactions.first) + let charlieCompaction = try XCTUnwrap(charlieCompactions.first) + + await rootEngine.enqueueL1CompactionSummary(alphaCompaction.outputText, from: "alpha") + await rootEngine.enqueueL1CompactionSummary(charlieCompaction.outputText, from: "charlie") + let rootSitrep = await rootEngine.latestSITREP() + let sitrep = try XCTUnwrap(rootSitrep) + XCTAssertFalse(sitrep.text.isEmpty) + + XCTAssertLessThanOrEqual(Date().timeIntervalSince(start), 120.0) + } + + // VAL-CROSS-008 + @MainActor + func testCrossAreaEncryptedCommunicationLateJoinerCanDecryptMessages() async throws { + let organiserTransport = MockBluetoothMeshTransport() + let organiserMesh = BluetoothMeshService( + transport: organiserTransport, + deduplicator: MessageDeduplicator(capacity: 1_000) + ) + let organiserSync = TreeSyncService(meshService: organiserMesh) + + let networkID = UUID(uuidString: "80808080-0000-0000-0000-000000000001")! + var securedConfig = NetworkConfig( + networkName: "Encrypted Cross", + networkID: networkID, + createdBy: "organiser-device", + pinHash: NetworkConfig.hashPIN("1234"), + version: 1, + tree: makeFixtureTree() + ) + securedConfig = organiserSync.secureConfigForPublishing(securedConfig) + organiserSync.setLocalConfig(securedConfig) + + let organiserPeerID = UUID(uuidString: "80808080-0000-0000-0000-000000000002")! + let lateJoinerPeerID = UUID(uuidString: "80808080-0000-0000-0000-000000000003")! + + let lateTransport = MockBluetoothMeshTransport() + lateTransport.setTreeConfig(securedConfig, for: organiserPeerID) + let lateMesh = BluetoothMeshService( + transport: lateTransport, + deduplicator: MessageDeduplicator(capacity: 1_000) + ) + let lateSync = TreeSyncService(meshService: lateMesh) + + let discovered = DiscoveredNetwork( + peerID: organiserPeerID, + networkID: networkID, + networkName: securedConfig.networkName, + openSlotCount: securedConfig.openSlotCount, + requiresPIN: true + ) + _ = try await lateSync.join(network: discovered, pin: "1234") + + organiserTransport.emit(.connectionStateChanged(lateJoinerPeerID, .connected)) + + let receivedByLateJoiner = LockedArray() + lateMesh.onMessageReceived = { receivedByLateJoiner.append($0) } + + let plaintextTranscript = "encrypted contact report" + let outbound = Message.make( + type: .broadcast, + senderID: "organiser-device", + senderRole: "organiser", + parentID: "root", + treeLevel: 1, + ttl: 4, + encrypted: false, + latitude: nil, + longitude: nil, + accuracy: nil, + transcript: plaintextTranscript + ) + organiserMesh.publish(outbound) + + let encryptedPacket = try XCTUnwrap(organiserTransport.sentPackets.first) + XCTAssertNil(encryptedPacket.data.range(of: Data(plaintextTranscript.utf8))) + + lateTransport.emit(.receivedData(encryptedPacket.data, from: organiserPeerID)) + + let didDecrypt = await waitForCondition(timeout: 1.0) { + receivedByLateJoiner.values.last?.payload.transcript == plaintextTranscript + } + XCTAssertTrue(didDecrypt) + XCTAssertEqual(receivedByLateJoiner.values.last?.payload.encrypted, true) + } + + // VAL-CROSS-009 + func testCrossAreaPriorityEscalationEndToEndBypassesNormalCycle() async throws { + let tree = makeFixtureTree() + let parentEngine = makeCompactionEngine( + localNodeID: "alpha", + localDeviceID: "alpha-device", + localRole: "Alpha Lead", + tree: tree, + summarizer: MockTacticalSummarizer(outputs: ["Casualty at grid five; immediate medevac."]), + configuration: .init(timeWindow: 30, messageCountThreshold: 10, defaultTTL: 8) + ) + await parentEngine.enqueueChildTranscript("Alpha-1 reports CASUALTY at grid five.", from: "alpha-1") + let parentEmissions = await parentEngine.emittedCompactions() + let parentEmission = try XCTUnwrap(parentEmissions.first) + XCTAssertEqual(parentEmission.triggerReason, .priorityKeyword) + + let rootEngine = makeCompactionEngine( + localNodeID: "root", + localDeviceID: "root-device", + localRole: "Commander", + tree: tree, + summarizer: MockTacticalSummarizer(outputs: ["SITREP priority: casualty at grid five."]), + configuration: .init(timeWindow: 30, messageCountThreshold: 10, defaultTTL: 8) + ) + await rootEngine.enqueueL1CompactionSummary(parentEmission.outputText, from: "alpha") + let rootSitrep = await rootEngine.latestSITREP() + let sitrep = try XCTUnwrap(rootSitrep) + XCTAssertEqual(sitrep.triggerReason, .priorityKeyword) + + let normalRootEngine = makeCompactionEngine( + localNodeID: "root", + localDeviceID: "root-device", + localRole: "Commander", + tree: tree, + summarizer: MockTacticalSummarizer(outputs: ["Normal sitrep"]), + configuration: .init(timeWindow: 30, messageCountThreshold: 2, defaultTTL: 8) + ) + await normalRootEngine.enqueueL1CompactionSummary("Routine status update.", from: "alpha") + let normalSitrep = await normalRootEngine.latestSITREP() + XCTAssertNil(normalSitrep) + } + + // VAL-CROSS-010 + @MainActor + func testCrossAreaDataFlowTransparencyDuringActiveCommunication() async throws { + let viewModel = DataFlowViewModel() + let summarizer = MockTacticalSummarizer(outputs: ["Compacted output for data flow visibility."]) + let engine = makeCompactionEngine( + localNodeID: "alpha", + localDeviceID: "alpha-device", + localRole: "Alpha Lead", + tree: makeFixtureTree(), + summarizer: summarizer, + configuration: .init(timeWindow: 30, messageCountThreshold: 1, defaultTTL: 8) + ) + + await engine.setProcessingObserver { metrics in + Task { @MainActor in + viewModel.handleProcessingMetrics(metrics) + } + } + await engine.setCompactionEmissionObserver { emission in + Task { @MainActor in + viewModel.handleOutgoingCompaction(emission) + } + } + + let incoming = Message.make( + type: .broadcast, + senderID: "alpha-1-device", + senderRole: "Alpha 1", + parentID: "alpha", + treeLevel: 2, + ttl: 4, + encrypted: false, + latitude: nil, + longitude: nil, + accuracy: nil, + transcript: "Routine update at checkpoint" + ) + viewModel.handleIncomingMessage(incoming) + + await engine.enqueueChildTranscript("Routine update at checkpoint", from: "alpha-1") + let outgoingObserved = await waitForCondition(timeout: 1.0) { + await MainActor.run { + !viewModel.outgoingEntries.isEmpty + } + } + XCTAssertTrue(outgoingObserved) + XCTAssertFalse(viewModel.incomingEntries.isEmpty) + XCTAssertEqual(viewModel.processing.triggerReason, .messageCount) + } + + // VAL-CROSS-011 + @MainActor + func testCrossAreaConcurrentRoleClaimConflictOrganiserWinsAndPeersConverge() throws { + let organiserTransport = MockBluetoothMeshTransport() + let organiserMesh = BluetoothMeshService( + transport: organiserTransport, + deduplicator: MessageDeduplicator(capacity: 1_000) + ) + let organiserSync = TreeSyncService(meshService: organiserMesh) + let forwardingPeer = UUID(uuidString: "B1111111-0000-0000-0000-000000000001")! + organiserTransport.emit(.connectionStateChanged(forwardingPeer, .connected)) + + let participantTransport = MockBluetoothMeshTransport() + let participantMesh = BluetoothMeshService( + transport: participantTransport, + deduplicator: MessageDeduplicator(capacity: 1_000) + ) + let participantSync = TreeSyncService(meshService: participantMesh) + participantTransport.emit(.connectionStateChanged(forwardingPeer, .connected)) + + var config = NetworkConfig( + networkName: "Claim Conflict", + networkID: UUID(uuidString: "B1111111-0000-0000-0000-000000000002")!, + createdBy: "organiser-device", + pinHash: nil, + version: 1, + tree: makeFixtureTree() + ) + _ = mutateClaim(nodeID: "alpha", claimedBy: nil, in: &config.tree) + organiserSync.setLocalConfig(config) + participantSync.setLocalConfig(config) + + let organiserRoleService = RoleClaimService( + meshService: organiserMesh, + treeSyncService: organiserSync, + localDeviceID: "organiser-device", + disconnectTimeout: 60 + ) + let participantRoleService = RoleClaimService( + meshService: participantMesh, + treeSyncService: participantSync, + localDeviceID: "participant-device", + disconnectTimeout: 60 + ) + + XCTAssertEqual(participantRoleService.claim(nodeID: "alpha"), .claimed(nodeID: "alpha")) + let participantClaimMessage = try XCTUnwrap( + participantTransport.sentPackets + .compactMap { try? decodeMessage(from: $0.data) } + .first(where: { $0.type == .claim }) + ) + organiserRoleService.handleIncomingMessage(participantClaimMessage) + + XCTAssertEqual(organiserRoleService.claim(nodeID: "alpha"), .claimed(nodeID: "alpha")) + let organiserClaimMessage = try XCTUnwrap( + organiserTransport.sentPackets + .compactMap { try? decodeMessage(from: $0.data) } + .first(where: { $0.type == .claim }) + ) + let rejectionMessage = try XCTUnwrap( + organiserTransport.sentPackets + .compactMap { try? decodeMessage(from: $0.data) } + .first(where: { $0.type == .claimRejected }) + ) + + participantRoleService.handleIncomingMessage(organiserClaimMessage) + participantRoleService.handleIncomingMessage(rejectionMessage) + + XCTAssertEqual(participantRoleService.lastClaimRejection, .organiserWins) + XCTAssertNil(participantRoleService.activeClaimNodeID) + XCTAssertEqual(claimedByValue(nodeID: "alpha", in: organiserSync.localConfig), "organiser-device") + XCTAssertEqual(claimedByValue(nodeID: "alpha", in: participantSync.localConfig), "organiser-device") + } + + // VAL-CROSS-012 + func testCrossAreaModelDownloadInterruptionRecoveryThenCactusInitSucceeds() async throws { + let sandbox = try makeModelDownloadSandbox() + defer { sandbox.cleanup() } + + let resumeData = Data("cross-area-resume-point".utf8) + let temporaryModelFile = try makeTemporaryModelFile(in: sandbox.baseDirectory) + let downloader = MockURLSessionDownloadClient( + scriptedResponses: [ + .init( + progressEvents: [(300, 1_000)], + result: .failure(URLSessionDownloadClientError.interrupted(resumeData: resumeData)) + ), + .init( + progressEvents: [(900, 1_000), (1_000, 1_000)], + result: .success(temporaryModelFile) + ) + ] + ) + let service = makeModelDownloadService( + sandbox: sandbox, + downloader: downloader, + availableStorageBytes: 20_000_000_000 + ) + + do { + _ = try await service.ensureModelAvailable() + XCTFail("Expected interruption on first download attempt") + } catch let error as ModelDownloadServiceError { + guard case let .interrupted(canResume) = error else { + return XCTFail("Expected interrupted error, got \(error)") + } + XCTAssertTrue(canResume) + } + + _ = try await service.ensureModelAvailable() + let initializer = CactusModelInitializationService( + downloadService: service, + initFunction: { _, _, _ in + UnsafeMutableRawPointer(bitPattern: 0xCAFEBABE)! + }, + destroyFunction: { _ in } + ) + let modelHandle = try await initializer.initializeModel() + XCTAssertEqual(modelHandle, UnsafeMutableRawPointer(bitPattern: 0xCAFEBABE)) + } + + // VAL-CROSS-013 + @MainActor + func testCrossAreaBackgroundingFlushesCompactionQueueAndForegroundRestartsMesh() async throws { + let transport = MockBluetoothMeshTransport() + let meshService = BluetoothMeshService( + transport: transport, + deduplicator: MessageDeduplicator(capacity: 1_000) + ) + let peerID = UUID(uuidString: "D1313131-0000-0000-0000-000000000001")! + transport.emit(.connectionStateChanged(peerID, .connected)) + + let summarizer = MockTacticalSummarizer(outputs: ["Background flush summary"]) + let coordinator = AppNetworkCoordinator( + meshService: meshService, + localDeviceID: "alpha-device", + mainAudioService: AudioService( + capturer: MockAudioCapturer(clips: []), + transcriber: MockCactusTranscriber(results: []), + maxRecordingDuration: 60 + ), + compactionEngineFactory: { localDeviceID, localNodeID, localSenderRole, initialTree, messageRouter in + CompactionEngine( + localDeviceID: localDeviceID, + localNodeID: localNodeID, + localSenderRole: localSenderRole, + initialTree: initialTree, + messageRouter: messageRouter, + summarizer: summarizer, + configuration: .init(timeWindow: 30, messageCountThreshold: 5, defaultTTL: 8) + ) + } + ) + + var config = NetworkConfig( + networkName: "Background Integration", + networkID: UUID(uuidString: "D1313131-0000-0000-0000-000000000002")!, + createdBy: "organiser-device", + pinHash: nil, + version: 1, + tree: makeFixtureTree() + ) + _ = mutateClaim(nodeID: "alpha", claimedBy: "alpha-device", in: &config.tree) + _ = mutateClaim(nodeID: "alpha-1", claimedBy: "alpha-1-device", in: &config.tree) + coordinator.treeSyncService.setLocalConfig(config) + + let roleReady = await waitForCondition(timeout: 1.0) { + await MainActor.run { + coordinator.roleClaimService.activeClaimNodeID == "alpha" + } + } + XCTAssertTrue(roleReady) + + let inboundBroadcast = Message.make( + type: .broadcast, + senderID: "alpha-1", + senderRole: "Alpha 1", + parentID: "alpha", + treeLevel: 2, + ttl: 4, + encrypted: false, + latitude: nil, + longitude: nil, + accuracy: nil, + transcript: "Contact while app backgrounds." + ) + let inboundData = try JSONEncoder().encode(inboundBroadcast) + transport.emit(.receivedData(inboundData, from: peerID)) + + XCTAssertTrue(transport.sentPackets.isEmpty, "Compaction should remain queued before background flush.") + + coordinator.handleScenePhase(.background) + + let flushedCompactionPublished = await waitForCondition(timeout: 1.0) { [self] in + transport.sentPackets.contains { packet in + (try? self.decodeMessage(from: packet.data).type) == .compaction + } + } + XCTAssertTrue(flushedCompactionPublished) + + transport.emit(.connectionStateChanged(peerID, .disconnected)) + coordinator.handleScenePhase(.active) + transport.emit(.connectionStateChanged(peerID, .connected)) + + XCTAssertGreaterThanOrEqual(transport.startCallCount, 1) + let pttReenabled = await waitForCondition(timeout: 1.0) { + await MainActor.run { + !coordinator.mainViewModel.isPTTDisabled + } + } + XCTAssertTrue(pttReenabled) + XCTAssertGreaterThanOrEqual(coordinator.afterActionReviewViewModel.totalMessageCount, 2) + } + + // VAL-CROSS-014 + @MainActor + func testCrossAreaGPSCoordinatesPreservedFromBroadcastToCompactionAndPersistence() { + let expectedLatitude = 37.3349 + let expectedLongitude = -122.0090 + let expectedAccuracy = 2.5 + let router = MessageRouter( + gpsProvider: { + .init( + latitude: expectedLatitude, + longitude: expectedLongitude, + accuracy: expectedAccuracy + ) + } + ) + let tree = makeFixtureTree() + let store = InMemoryAfterActionReviewStore() + + let broadcast = router.makeBroadcastMessage( + transcript: "Leaf reports contact at checkpoint.", + senderID: "alpha-1-device", + senderNodeID: "alpha-1", + senderRole: "Alpha 1", + in: tree + ) + let compaction = router.makeCompactionMessage( + summary: "Alpha summary with checkpoint contact.", + senderID: "alpha-device", + senderNodeID: "alpha", + senderRole: "Alpha Lead", + in: tree + ) + + store.persist(broadcast) + store.persist(compaction) + + let persisted = store.search(query: "checkpoint") + XCTAssertEqual(persisted.count, 2) + XCTAssertTrue( + persisted.allSatisfy { + abs($0.latitude - expectedLatitude) < 0.000_001 && + abs($0.longitude - expectedLongitude) < 0.000_001 && + abs($0.accuracy - expectedAccuracy) < 0.000_001 && + !$0.isFallbackLocation + } + ) + } + + private func makeCompactionEngine( + localNodeID: String, + localDeviceID: String, + localRole: String, + tree: TreeNode, + summarizer: any TacticalSummarizing, + configuration: CompactionEngine.Configuration + ) -> CompactionEngine { + CompactionEngine( + localDeviceID: localDeviceID, + localNodeID: localNodeID, + localSenderRole: localRole, + initialTree: tree, + summarizer: summarizer, + configuration: configuration + ) + } + + private func waitForCondition( + timeout: TimeInterval, + pollIntervalNanoseconds: UInt64 = 10_000_000, + condition: @escaping @Sendable () async -> Bool + ) async -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if await condition() { + return true + } + try? await Task.sleep(nanoseconds: pollIntervalNanoseconds) + } + return await condition() + } + + private func makePCMClip( + samples: [Int16], + sampleRate: Int = 16_000, + channels: Int = 1, + bitsPerSample: Int = 16 + ) -> RecordedAudioClip { + var data = Data(count: samples.count * MemoryLayout.size) + data.withUnsafeMutableBytes { destination in + samples.withUnsafeBytes { source in + destination.copyMemory(from: source) + } + } + return RecordedAudioClip( + data: data, + sampleRate: sampleRate, + channels: channels, + bitsPerSample: bitsPerSample + ) + } + + private func makeAlternatingPCMClip( + sampleCount: Int, + amplitude: Int16, + sampleRate: Int = 16_000 + ) -> RecordedAudioClip { + var data = Data(count: sampleCount * MemoryLayout.size) + data.withUnsafeMutableBytes { rawBuffer in + let samples = rawBuffer.bindMemory(to: Int16.self) + for index in 0.. Message { + Message.make( + id: id, + type: type, + senderID: "node-alpha", + senderRole: "leaf", + parentID: "node-parent", + treeLevel: 2, + ttl: ttl, + encrypted: false, + latitude: 10.0, + longitude: 20.0, + accuracy: 3.0, + transcript: "test-message" + ) + } + + private func decodeMessage(from data: Data) throws -> Message { + try JSONDecoder().decode(Message.self, from: data) + } + + private func makeNetworkConfig( + networkID: UUID, + version: Int, + rootLabel: String, + pin: String? = nil + ) -> NetworkConfig { + NetworkConfig( + networkName: "Net-\(version)", + networkID: networkID, + createdBy: "organiser", + pinHash: NetworkConfig.hashPIN(pin), + version: version, + tree: TreeNode( + id: "root", + label: rootLabel, + claimedBy: nil, + children: [ + TreeNode(id: "alpha", label: "Alpha", claimedBy: nil, children: []), + TreeNode(id: "bravo", label: "Bravo", claimedBy: "claimed-device", children: []) + ] + ) + ) + } + + private func makeAutoReparentNetworkConfig( + networkID: UUID, + version: Int, + rootOwnerID: String, + alphaOwnerID: String, + bravoOwnerID: String, + charlieOwnerID: String + ) -> NetworkConfig { + NetworkConfig( + networkName: "Resilience Net", + networkID: networkID, + createdBy: rootOwnerID, + pinHash: nil, + version: version, + tree: TreeNode( + id: "root", + label: "Root", + claimedBy: rootOwnerID, + children: [ + TreeNode( + id: "alpha", + label: "Alpha", + claimedBy: alphaOwnerID, + children: [ + TreeNode( + id: "bravo", + label: "Bravo", + claimedBy: bravoOwnerID, + children: [ + TreeNode( + id: "charlie", + label: "Charlie", + claimedBy: charlieOwnerID, + children: [] + ) + ] + ) + ] + ) + ] + ) + ) + } + + private func withClaim(nodeID: String, claimedBy: String?, in config: NetworkConfig) -> NetworkConfig { + var updated = config + _ = mutateClaim(nodeID: nodeID, claimedBy: claimedBy, in: &updated.tree) + return updated + } + + private func claimedByValue(nodeID: String, in config: NetworkConfig?) -> String? { + guard let config else { + return nil + } + return findNode(nodeID: nodeID, in: config.tree)?.claimedBy + } + + @discardableResult + private func mutateClaim(nodeID: String, claimedBy: String?, in tree: inout TreeNode) -> Bool { + if tree.id == nodeID { + tree.claimedBy = claimedBy + return true + } + + for index in tree.children.indices { + if mutateClaim(nodeID: nodeID, claimedBy: claimedBy, in: &tree.children[index]) { + return true + } + } + return false + } + + private func findNode(nodeID: String, in tree: TreeNode) -> TreeNode? { + if tree.id == nodeID { + return tree + } + + for child in tree.children { + if let found = findNode(nodeID: nodeID, in: child) { + return found + } + } + return nil + } + + private func makeFixtureTree() -> TreeNode { + TreeNode( + id: "root", + label: "Root", + claimedBy: nil, + children: [ + TreeNode( + id: "alpha", + label: "Alpha", + claimedBy: nil, + children: [ + TreeNode(id: "alpha-1", label: "Alpha 1", claimedBy: nil, children: []), + TreeNode(id: "alpha-2", label: "Alpha 2", claimedBy: nil, children: []) + ] + ), + TreeNode( + id: "bravo", + label: "Bravo", + claimedBy: nil, + children: [] + ), + TreeNode( + id: "charlie", + label: "Charlie", + claimedBy: nil, + children: [ + TreeNode(id: "charlie-1", label: "Charlie 1", claimedBy: nil, children: []) + ] + ) + ] + ) + } + + private func deterministicUUID(from value: Int) -> UUID { + UUID(uuidString: String(format: "00000000-0000-0000-0000-%012X", value))! + } + + @MainActor + private func makeRoleClaimContextForSettings( + localDeviceID: String, + createdBy: String, + claims: [String: String] = [:] + ) -> (transport: MockBluetoothMeshTransport, syncService: TreeSyncService, roleService: RoleClaimService) { + let transport = MockBluetoothMeshTransport() + let meshService = BluetoothMeshService( + transport: transport, + deduplicator: MessageDeduplicator(capacity: 1_000) + ) + let syncService = TreeSyncService(meshService: meshService) + transport.emit( + .connectionStateChanged( + UUID(uuidString: "D0D0D0D0-0000-0000-0000-000000000001")!, + .connected + ) + ) + + var config = NetworkConfig( + networkName: "TacNet Settings", + networkID: UUID(uuidString: "DEADBEEF-CAFE-BABE-FADE-000000000001")!, + createdBy: createdBy, + pinHash: nil, + version: 1, + tree: makeFixtureTree() + ) + for (nodeID, ownerID) in claims { + _ = mutateClaim(nodeID: nodeID, claimedBy: ownerID, in: &config.tree) + } + syncService.setLocalConfig(config) + + let roleService = RoleClaimService( + meshService: meshService, + treeSyncService: syncService, + localDeviceID: localDeviceID, + disconnectTimeout: 60 + ) + return (transport, syncService, roleService) + } + + private func makeModelDownloadSandbox() throws -> ModelDownloadSandbox { + let suiteName = "TacNetTests.ModelDownload.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defaults.removePersistentDomain(forName: suiteName) + + let baseDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("TacNetTests-\(UUID().uuidString)", isDirectory: true) + let appSupportDirectory = baseDirectory.appendingPathComponent("ApplicationSupport", isDirectory: true) + try FileManager.default.createDirectory(at: appSupportDirectory, withIntermediateDirectories: true) + + return ModelDownloadSandbox( + suiteName: suiteName, + userDefaults: defaults, + baseDirectory: baseDirectory, + appSupportDirectory: appSupportDirectory + ) + } + + private func makeTemporaryModelFile(in directory: URL) throws -> URL { + let fileURL = directory.appendingPathComponent("mock-downloaded-model-\(UUID().uuidString).bin") + try Data("mock-model-contents".utf8).write(to: fileURL, options: .atomic) + return fileURL + } + + private func makeModelDownloadConfiguration( + requiresZipArchive: Bool = false + ) -> ModelDownloadConfiguration { + // Tests default to `requiresZipArchive: false` because the existing + // `MockURLSessionDownloadClient` emits tiny non-zip byte blobs as the + // "downloaded" artifact. Production (`.live`) keeps the default `true` + // so real HTTP error bodies never reach the sentinel path. + ModelDownloadConfiguration( + modelURL: URL(string: "https://huggingface.co/Cactus-Compute/gemma-4-e4b-int4/resolve/main/gemma-4-e4b-int4.bin")!, + expectedModelSizeBytes: 6_700_000_000, + modelDirectoryName: "gemma-4-e4b-int4", + modelFileName: "gemma-4-e4b-int4.bin", + requiresZipArchive: requiresZipArchive + ) + } + + private func makeModelDownloadService( + sandbox: ModelDownloadSandbox, + downloader: URLSessionDownloading, + availableStorageBytes: Int64, + requiresZipArchive: Bool = false + ) -> ModelDownloadService { + ModelDownloadService( + configuration: makeModelDownloadConfiguration(requiresZipArchive: requiresZipArchive), + downloader: downloader, + storageChecker: MockStorageChecker(availableBytes: availableStorageBytes), + fileManager: FileManager.default, + userDefaults: sandbox.userDefaults, + applicationSupportDirectory: sandbox.appSupportDirectory, + persistenceKeyPrefix: "TacNetTests.ModelDownload.\(UUID().uuidString)" + ) + } +} + +private final class MockBluetoothMeshTransport: BluetoothMeshTransporting { + struct SentPacket { + let data: Data + let messageType: Message.MessageType + let peerIDs: Set + } + + var eventHandler: ((BluetoothMeshTransportEvent) -> Void)? + private(set) var sentPackets: [SentPacket] = [] + private var treeConfigByPeer: [UUID: Data] = [:] + private(set) var lastConfiguredAdvertisement: NetworkAdvertisement? + private(set) var configuredTreeConfigPayload: Data = Data() + private(set) var startCallCount = 0 + private(set) var stopCallCount = 0 + + func start() { + startCallCount += 1 + } + + func stop() { + stopCallCount += 1 + } + + func send(_ data: Data, messageType: Message.MessageType, to peerIDs: Set) { + sentPackets.append(SentPacket(data: data, messageType: messageType, peerIDs: peerIDs)) + } + + func emit(_ event: BluetoothMeshTransportEvent) { + eventHandler?(event) + } + + func configureAdvertisement(_ summary: NetworkAdvertisement?) { + lastConfiguredAdvertisement = summary + } + + func updateTreeConfigPayload(_ data: Data) { + configuredTreeConfigPayload = data + } + + func requestTreeConfig(from peerID: UUID, completion: @escaping (Result) -> Void) { + if let payload = treeConfigByPeer[peerID] { + completion(.success(payload)) + return + } + + guard !configuredTreeConfigPayload.isEmpty else { + completion(.failure(BluetoothMeshTransportError.treeConfigUnavailable)) + return + } + + completion(.success(configuredTreeConfigPayload)) + } + + func setTreeConfig(_ networkConfig: NetworkConfig, for peerID: UUID) { + treeConfigByPeer[peerID] = try? JSONEncoder().encode(networkConfig) + } +} + +private struct ModelDownloadSandbox { + let suiteName: String + let userDefaults: UserDefaults + let baseDirectory: URL + let appSupportDirectory: URL + + func cleanup() { + userDefaults.removePersistentDomain(forName: suiteName) + try? FileManager.default.removeItem(at: baseDirectory) + } +} + +private struct MockStorageChecker: StorageChecking { + let availableBytes: Int64 + + func availableStorageBytes(for _: URL) throws -> Int64 { + availableBytes + } +} + +private final class MockURLSessionDownloadClient: URLSessionDownloading, @unchecked Sendable { + struct ScriptedResponse { + let progressEvents: [(written: Int64, total: Int64)] + let result: Result + let responseDelayNanoseconds: UInt64 + + init( + progressEvents: [(written: Int64, total: Int64)], + result: Result, + responseDelayNanoseconds: UInt64 = 0 + ) { + self.progressEvents = progressEvents + self.result = result + self.responseDelayNanoseconds = responseDelayNanoseconds + } + } + + private let lock = NSLock() + private var requests: [ModelDownloadRequest] = [] + private var scriptedResponses: [ScriptedResponse] + + init(scriptedResponses: [ScriptedResponse]) { + self.scriptedResponses = scriptedResponses + } + + var requestCount: Int { + lock.withLock { + requests.count + } + } + + func request(at index: Int) -> ModelDownloadRequest? { + lock.withLock { + guard requests.indices.contains(index) else { return nil } + return requests[index] + } + } + + func download( + request: ModelDownloadRequest, + progress: @escaping @Sendable (Int64, Int64) -> Void + ) async throws -> URL { + let response: ScriptedResponse = lock.withLock { + requests.append(request) + guard !scriptedResponses.isEmpty else { + return ScriptedResponse( + progressEvents: [], + result: .failure(NSError(domain: "MockURLSessionDownloadClient", code: -1)) + ) + } + return scriptedResponses.removeFirst() + } + + response.progressEvents.forEach { progress($0.written, $0.total) } + if response.responseDelayNanoseconds > 0 { + try? await Task.sleep(nanoseconds: response.responseDelayNanoseconds) + } + + switch response.result { + case let .success(url): + return url + case let .failure(error): + throw error + } + } +} + +private actor MockAudioCapturer: AudioCapturing { + private var clips: [RecordedAudioClip] + private var isCapturing = false + + init(clips: [RecordedAudioClip]) { + self.clips = clips + } + + func startCapture() async throws { + isCapturing = true + } + + func stopCapture() async throws -> RecordedAudioClip { + guard isCapturing else { + throw AudioServiceError.notRecording + } + isCapturing = false + guard !clips.isEmpty else { + return RecordedAudioClip(data: Data(), sampleRate: 16_000, channels: 1, bitsPerSample: 16) + } + return clips.removeFirst() + } +} + +private actor MockCactusTranscriber: CactusTranscribing { + private var results: [String] + private let delayNanoseconds: UInt64 + private var inputs: [Data] = [] + + init(results: [String], delayNanoseconds: UInt64 = 0) { + self.results = results + self.delayNanoseconds = delayNanoseconds + } + + func transcribePCM16kMono(_ pcmData: Data) async throws -> String { + inputs.append(pcmData) + if delayNanoseconds > 0 { + try? await Task.sleep(nanoseconds: delayNanoseconds) + } + guard !results.isEmpty else { + return "" + } + return results.removeFirst() + } + + func receivedPCMInputs() -> [Data] { + inputs + } +} + +private actor MockTranscriptConsumer: TranscriptConsuming { + private var transcripts: [AudioService.TranscriptResult] = [] + + func receiveTranscript(_ transcript: AudioService.TranscriptResult) async { + transcripts.append(transcript) + } + + func received() -> [AudioService.TranscriptResult] { + transcripts + } +} + +private actor MockTacticalSummarizer: TacticalSummarizing { + struct Invocation: Equatable, Sendable { + let systemPrompt: String + let userPrompt: String + } + + private var outputs: [String] + private let delayNanoseconds: UInt64 + private var recordedInvocations: [Invocation] = [] + + init(outputs: [String], delayNanoseconds: UInt64 = 0) { + self.outputs = outputs + self.delayNanoseconds = delayNanoseconds + } + + func summarize(systemPrompt: String, userPrompt: String) async throws -> String { + recordedInvocations.append(Invocation(systemPrompt: systemPrompt, userPrompt: userPrompt)) + if delayNanoseconds > 0 { + try? await Task.sleep(nanoseconds: delayNanoseconds) + } + if outputs.isEmpty { + return userPrompt + } + return outputs.removeFirst() + } + + func invocations() -> [Invocation] { + recordedInvocations + } +} + +private final class CapturingSecurityLogger: SecurityEventLogging, @unchecked Sendable { + private let lock = NSLock() + private var entries: [String] = [] + + func log(_ message: String) { + lock.withLock { + entries.append(message) + } + } + + var messages: [String] { + lock.withLock { + entries + } + } +} + +private final class LockedArray: @unchecked Sendable { + private let lock = NSLock() + private var storage: [Element] = [] + + func append(_ element: Element) { + lock.withLock { + storage.append(element) + } + } + + var values: [Element] { + lock.withLock { + storage + } + } +} + +private extension NSLock { + func withLock(_ body: () throws -> T) rethrows -> T { + lock() + defer { unlock() } + return try body() + } +} diff --git a/TacNetUITests/TacNetUITests.swift b/TacNetUITests/TacNetUITests.swift new file mode 100644 index 00000000..edd1525a --- /dev/null +++ b/TacNetUITests/TacNetUITests.swift @@ -0,0 +1,875 @@ +// +// TacNetUITests.swift +// +// Automated XCUITest smoke walkthrough of every reachable non-BLE screen in the +// TacNet iOS app. Drives the app on the iPhone 17 Simulator via launch +// arguments that bypass the 6.7 GB model download gate and routes to +// synthetic hosts for screens that require real BLE (e.g. PIN entry). +// +// Mapped to validation contract assertions VAL-UI-001 through VAL-UI-013. +// + +import XCTest + +final class TacNetUISmokeTests: XCTestCase { + + // MARK: - Setup + + override func setUp() { + super.setUp() + continueAfterFailure = false + } + + // MARK: - Helpers + + private func makeApp( + route: String? = nil, + additionalArguments: [String] = [] + ) -> XCUIApplication { + let app = XCUIApplication() + var args = ["--ui-test-skip-download"] + if let route { + args.append("--ui-test-route=\(route)") + } + args.append(contentsOf: additionalArguments) + app.launchArguments = args + return app + } + + private func saveScreenshot(_ app: XCUIApplication, named name: String) { + let screenshot = app.windows.firstMatch.screenshot() + let attachment = XCTAttachment(screenshot: screenshot) + attachment.name = name + attachment.lifetime = .keepAlways + add(attachment) + } + + private func waitForExistence( + _ element: XCUIElement, + timeout: TimeInterval = 10, + message: String = "" + ) { + let exists = element.waitForExistence(timeout: timeout) + XCTAssertTrue( + exists, + "\(message) Element \(element) was not found within \(timeout)s" + ) + } + + /// Resolves an accessibility identifier irrespective of XCUIElement type (Other, + /// ScrollView, StaticText, etc.) which depends on how SwiftUI composes the view. + private func anyElement(_ app: XCUIApplication, identifier: String) -> XCUIElement { + app.descendants(matching: .any) + .matching(identifier: identifier) + .firstMatch + } + + // MARK: - VAL-UI-001 · App launch, no crash, UI visible within 5s + + func testAppLaunchesAndRemainsAliveWithVisibleUI() { + let app = makeApp() + app.launch() + + let welcomeRoot = app.otherElements["tacnet.welcome.root"] + waitForExistence(welcomeRoot, timeout: 8, message: "Welcome did not render after launch.") + saveScreenshot(app, named: "launch-welcome") + + // App should remain alive for at least 15 seconds with UI still visible. + let totalWaitSeconds: TimeInterval = 15 + let startDate = Date() + while Date().timeIntervalSince(startDate) < totalWaitSeconds { + XCTAssertEqual( + app.state, + .runningForeground, + "App left foreground state before \(totalWaitSeconds)s" + ) + _ = XCUIApplication().wait(for: .runningForeground, timeout: 1) + } + saveScreenshot(app, named: "launch-after-15s") + } + + // MARK: - VAL-UI-002 · Real bootstrap/download gate renders with accurate gating UI + + /// Launches the app WITHOUT `--ui-test-skip-download`, using the deterministic + /// `stuck` download fixture so the gate remains visible for the full test window. + /// Asserts that the real `downloadGate` rendering satisfies VAL-UI-002: + /// * gate container, title, progress bar are visible + /// * "TacNet features are locked…" copy is visible + /// * retry button is hidden until an error occurs + /// * the app does not crash for ≥10 seconds on the download screen + func testBootstrapDownloadGateRendersAndGatingUIIsAccurate() { + let app = XCUIApplication() + // NOTE: no --ui-test-skip-download. The `stuck` fixture keeps the gate + // visible at 0% progress forever without pulling the real 6.7 GB payload. + app.launchArguments = ["--ui-test-download-fixture=stuck"] + app.launch() + + let gateRoot = app.otherElements["tacnet.downloadGate.root"] + waitForExistence(gateRoot, timeout: 10, message: "Download gate did not render.") + saveScreenshot(app, named: "download-gate-initial") + + // Title + progress bar + locked-copy must be visible on the gate. + let title = anyElement(app, identifier: "tacnet.downloadGate.title") + waitForExistence(title, timeout: 4, message: "Download gate title label is missing.") + let progressBar = anyElement(app, identifier: "tacnet.downloadGate.progressBar") + waitForExistence(progressBar, timeout: 4, message: "Download gate progress bar is missing.") + let lockedCopy = anyElement(app, identifier: "tacnet.downloadGate.lockedCopy") + waitForExistence(lockedCopy, timeout: 4, message: "Download gate locked-features copy is missing.") + + // Retry button is error-only; it must NOT exist while there is no error. + let retryButton = app.buttons["tacnet.downloadGate.retryButton"] + XCTAssertFalse( + retryButton.exists, + "Retry button should be hidden when no error has been surfaced." + ) + + // App must not crash for at least 10 seconds while the gate holds. + let totalWaitSeconds: TimeInterval = 10 + let startDate = Date() + while Date().timeIntervalSince(startDate) < totalWaitSeconds { + XCTAssertEqual( + app.state, + .runningForeground, + "App left foreground state before \(totalWaitSeconds)s on the download gate." + ) + _ = XCUIApplication().wait(for: .runningForeground, timeout: 1) + } + + // Gate remains visible after the 10s hold and still no retry button. + XCTAssertTrue(gateRoot.exists, "Download gate disappeared before the 10s hold elapsed.") + XCTAssertFalse( + app.buttons["tacnet.downloadGate.retryButton"].exists, + "Retry button became visible unexpectedly during the 10s gate hold." + ) + saveScreenshot(app, named: "download-gate-after-10s") + } + + // MARK: - VAL-UI-002 · Initial screen renders appropriately + + func testInitialScreenRendersWithoutBlankState() { + let app = makeApp() + app.launch() + + let welcomeRoot = app.otherElements["tacnet.welcome.root"] + waitForExistence(welcomeRoot, timeout: 8) + + // Welcome screen must expose both affordances once the download gate is bypassed. + XCTAssertTrue(app.buttons["tacnet.welcome.createNetworkButton"].exists) + XCTAssertTrue(app.buttons["tacnet.welcome.joinNetworkButton"].exists) + saveScreenshot(app, named: "initial-welcome") + } + + // MARK: - VAL-UI-003 · Welcome navigates to Create and Join + + func testWelcomeNavigatesToCreateAndJoin() { + let app = makeApp() + app.launch() + let createButton = app.buttons["tacnet.welcome.createNetworkButton"] + let joinButton = app.buttons["tacnet.welcome.joinNetworkButton"] + waitForExistence(createButton) + + // Tap Create Network — should navigate to Tree Builder. + createButton.tap() + let treeBuilderRoot = app.scrollViews["tacnet.treeBuilder.root"] + .firstMatch.exists ? app.scrollViews["tacnet.treeBuilder.root"] : app.otherElements["tacnet.treeBuilder.root"] + // Fall back to existence check via any Tree Builder control we know is present. + let addChildButton = app.buttons["tacnet.treeBuilder.addChildButton"] + waitForExistence(addChildButton, timeout: 6, message: "Tree Builder did not render.") + saveScreenshot(app, named: "create-network-treebuilder") + + // Back to welcome. + let backButton = app.buttons["tacnet.treeBuilder.backButton"] + if backButton.exists { + backButton.tap() + } else { + app.navigationBars.buttons.element(boundBy: 0).tap() + } + waitForExistence(joinButton, timeout: 6, message: "Welcome did not re-appear after Tree Builder back.") + + // Tap Join Network — should navigate to Network Scan. + joinButton.tap() + let scanRoot = app.otherElements["tacnet.scan.root"] + waitForExistence(scanRoot, timeout: 6, message: "Network Scan did not render.") + saveScreenshot(app, named: "join-network-scan") + } + + // MARK: - VAL-UI-004 · Tree Builder add/rename/remove flow + + func testTreeBuilderAddRenameRemoveCycle() { + let app = makeApp() + app.launch() + app.buttons["tacnet.welcome.createNetworkButton"].tap() + + let addChildButton = app.buttons["tacnet.treeBuilder.addChildButton"] + waitForExistence(addChildButton, timeout: 6) + saveScreenshot(app, named: "treebuilder-empty") + + // Add a child node. + let newChildField = app.textFields["tacnet.treeBuilder.newChildField"] + waitForExistence(newChildField) + newChildField.tap() + newChildField.typeText("Alpha") + addChildButton.tap() + saveScreenshot(app, named: "treebuilder-after-add") + XCTAssertTrue( + app.staticTexts["Alpha"].waitForExistence(timeout: 4), + "Newly added child 'Alpha' did not appear in the tree." + ) + + // Rename the selected node (the just-added child is auto-selected). + let renameField = app.textFields["tacnet.treeBuilder.renameField"] + waitForExistence(renameField) + renameField.tap() + // Clear existing text by selecting all then typing. + if let existing = renameField.value as? String, !existing.isEmpty { + let deleteString = String(repeating: XCUIKeyboardKey.delete.rawValue, count: existing.count) + renameField.typeText(deleteString) + } + renameField.typeText("Bravo") + app.buttons["tacnet.treeBuilder.renameButton"].tap() + saveScreenshot(app, named: "treebuilder-after-rename") + XCTAssertTrue( + app.staticTexts["Bravo"].waitForExistence(timeout: 4), + "Renamed node 'Bravo' did not appear in the tree." + ) + + // Remove the node. + app.buttons["tacnet.treeBuilder.removeButton"].tap() + saveScreenshot(app, named: "treebuilder-after-remove") + // After removal the 'Bravo' label should be gone. + let bravoGone = !app.staticTexts["Bravo"].waitForExistence(timeout: 2) + XCTAssertTrue(bravoGone, "Removed node 'Bravo' is still visible in the tree.") + } + + // MARK: - VAL-UI-005 · Network Scan empty state + + func testNetworkScanRendersEmptyStateWithoutBLE() { + let app = makeApp() + app.launch() + app.buttons["tacnet.welcome.joinNetworkButton"].tap() + + let scanRoot = app.otherElements["tacnet.scan.root"] + waitForExistence(scanRoot, timeout: 6) + saveScreenshot(app, named: "scan-empty") + + // Rescan and back should not crash. + let rescan = app.buttons["tacnet.scan.rescanButton"] + waitForExistence(rescan) + rescan.tap() + saveScreenshot(app, named: "scan-after-rescan") + + app.buttons["tacnet.scan.backButton"].tap() + waitForExistence( + app.buttons["tacnet.welcome.createNetworkButton"], + timeout: 4, + message: "Did not return to Welcome after Back from Scan." + ) + } + + // MARK: - VAL-UI-006 · PIN entry renders and accepts input + + func testPinEntryRendersAndAcceptsDigits() { + let app = makeApp(route: "pin-entry") + app.launch() + + let pinField = app.secureTextFields["tacnet.pin.field"] + waitForExistence(pinField, timeout: 6, message: "Pin entry field did not render.") + saveScreenshot(app, named: "pin-entry") + + pinField.tap() + pinField.typeText("1234") + + let submit = app.buttons["tacnet.pin.submitButton"] + waitForExistence(submit) + submit.tap() + + // Our UI-test host surfaces the submitted value for verification. + let submitted = app.staticTexts["tacnet.pin.submittedValue"] + waitForExistence(submitted, timeout: 4, message: "Pin submission did not reach host observer.") + saveScreenshot(app, named: "pin-entry-submitted") + } + + // MARK: - VAL-UI-007 · Role Selection with seeded tree + + func testRoleSelectionRendersSeededTreeAndClaimControl() { + let app = launchAndPublishSeededNetwork() + + let roleRoot = app.otherElements["tacnet.roleSelection.root"] + waitForExistence(roleRoot, timeout: 8, message: "Role Selection did not render.") + saveScreenshot(app, named: "role-selection") + + // Seeded tree has a root + at least one claimable child (we added one). + let alphaRow = app.staticTexts["Alpha"] + XCTAssertTrue( + alphaRow.waitForExistence(timeout: 4), + "Role Selection does not show seeded 'Alpha' node." + ) + + // The Back button should always be present. + XCTAssertTrue(app.buttons["tacnet.roleSelection.backButton"].exists) + } + + // MARK: - VAL-UI-008 through VAL-UI-012 · Tabs render and switch without crash + + func testMainTreeDataFlowSettingsTabsRenderAndSwitch() { + let app = launchAndPublishSeededNetwork() + + // Claim the seeded child node to reach the main tab shell. The row's + // accessibility identifier is "tacnet.roleSelection.row."; + // we locate it by the visible label we set (Alpha) inside any matching + // button cell. + let alphaStaticText = app.staticTexts["Alpha"] + waitForExistence(alphaStaticText, timeout: 8) + + // Tap the enclosing button cell for reliability. + let claimableCell = app.buttons.matching(NSPredicate(format: "identifier BEGINSWITH %@", "tacnet.roleSelection.row.")).element(matching: NSPredicate(format: "label CONTAINS %@", "Alpha")) + if claimableCell.exists { + claimableCell.tap() + } else { + alphaStaticText.tap() + } + saveScreenshot(app, named: "after-claim-tap") + + let tabBar = app.tabBars.firstMatch + waitForExistence(tabBar, timeout: 12, message: "Tab bar did not appear after role claim.") + + // Main tab — PTT button interacts without crash. + let mainTab = tabBar.buttons["Main"] + waitForExistence(mainTab) + mainTab.tap() + let mainRoot = anyElement(app, identifier: "tacnet.main.root") + waitForExistence(mainRoot, timeout: 6, message: "Main tab did not render.") + saveScreenshot(app, named: "tab-main") + + // The PTT control is combined into a single element — look up by id. + let pttControl = anyElement(app, identifier: "tacnet.main.pttControl") + waitForExistence(pttControl, timeout: 6, message: "PTT control did not render on Main tab.") + // Press (no crash expected even if mesh is disconnected — must NOT hang). + pttControl.press(forDuration: 0.3) + saveScreenshot(app, named: "tab-main-after-ptt") + + // Tree tab. + let treeTab = tabBar.buttons["Tree View"] + if treeTab.exists { + treeTab.tap() + } + waitForExistence(anyElement(app, identifier: "tacnet.tree.root"), timeout: 4, message: "Tree tab did not render.") + saveScreenshot(app, named: "tab-tree") + + // Data Flow tab. + let dataFlowTab = tabBar.buttons["Data Flow"] + if dataFlowTab.exists { + dataFlowTab.tap() + } + waitForExistence(anyElement(app, identifier: "tacnet.dataflow.root"), timeout: 4, message: "Data Flow tab did not render.") + XCTAssertTrue(app.staticTexts["INCOMING"].exists, "Data Flow tab missing INCOMING section header") + XCTAssertTrue(app.staticTexts["PROCESSING"].exists, "Data Flow tab missing PROCESSING section header") + XCTAssertTrue(app.staticTexts["OUTGOING"].exists, "Data Flow tab missing OUTGOING section header") + saveScreenshot(app, named: "tab-dataflow") + + // Settings tab. + let settingsTab = tabBar.buttons["Settings"] + if settingsTab.exists { + settingsTab.tap() + } + waitForExistence(anyElement(app, identifier: "tacnet.settings.root"), timeout: 4, message: "Settings tab did not render.") + saveScreenshot(app, named: "tab-settings") + + // Cycle back through tabs in another order — no crash expected. + mainTab.tap() + waitForExistence(anyElement(app, identifier: "tacnet.main.root"), timeout: 4) + if dataFlowTab.exists { dataFlowTab.tap() } + waitForExistence(anyElement(app, identifier: "tacnet.dataflow.root"), timeout: 4) + if treeTab.exists { treeTab.tap() } + waitForExistence(anyElement(app, identifier: "tacnet.tree.root"), timeout: 4) + if settingsTab.exists { settingsTab.tap() } + waitForExistence(anyElement(app, identifier: "tacnet.settings.root"), timeout: 4) + saveScreenshot(app, named: "tab-cycle-final") + + // App must still be running after the full walk. + XCTAssertEqual(app.state, .runningForeground, "App is not running after full tab walkthrough.") + } + + // MARK: - VAL-UI-011 · Settings role-appropriate affordances + + /// Covers VAL-UI-011: organiser sees tree-editor entry + promote + release-role, + /// while a participant sees only release-role (organiser-only controls hidden). + /// Uses the `--ui-test-route=settings` host with `--ui-test-role=` to seed + /// a deterministic network state without depending on BLE discovery. + func testSettingsTabShowsRoleAppropriateAffordances() { + // --- Organiser: all three affordances visible. + let organiserApp = XCUIApplication() + organiserApp.launchArguments = [ + "--ui-test-route=settings", + "--ui-test-role=organiser", + ] + organiserApp.launch() + + let organiserSettingsRoot = anyElement(organiserApp, identifier: "tacnet.settings.root") + waitForExistence( + organiserSettingsRoot, + timeout: 10, + message: "Settings root did not render in organiser host." + ) + let organiserEditTree = organiserApp.buttons["tacnet.settings.editTreeButton"] + let organiserPromote = organiserApp.buttons["tacnet.settings.promoteButton"] + let organiserRelease = organiserApp.buttons["tacnet.settings.releaseRoleButton"] + XCTAssertTrue( + organiserEditTree.waitForExistence(timeout: 4), + "Organiser should see Edit Tree button in Settings." + ) + XCTAssertTrue( + organiserPromote.waitForExistence(timeout: 4), + "Organiser should see Promote button in Settings." + ) + XCTAssertTrue( + organiserRelease.waitForExistence(timeout: 4), + "Organiser should see Release Role button in Settings." + ) + saveScreenshot(organiserApp, named: "settings-organiser") + organiserApp.terminate() + + // --- Participant: only release-role visible; organiser controls hidden. + let participantApp = XCUIApplication() + participantApp.launchArguments = [ + "--ui-test-route=settings", + "--ui-test-role=participant", + ] + participantApp.launch() + + let participantSettingsRoot = anyElement(participantApp, identifier: "tacnet.settings.root") + waitForExistence( + participantSettingsRoot, + timeout: 10, + message: "Settings root did not render in participant host." + ) + let participantRelease = participantApp.buttons["tacnet.settings.releaseRoleButton"] + XCTAssertTrue( + participantRelease.waitForExistence(timeout: 4), + "Participant should see Release Role button in Settings." + ) + XCTAssertFalse( + participantApp.buttons["tacnet.settings.editTreeButton"].exists, + "Participant must NOT see Edit Tree button in Settings." + ) + XCTAssertFalse( + participantApp.buttons["tacnet.settings.promoteButton"].exists, + "Participant must NOT see Promote button in Settings." + ) + saveScreenshot(participantApp, named: "settings-participant") + participantApp.terminate() + } + + // MARK: - VAL-PTT-002 / VAL-PTT-003 · PTT button gesture dispatch + no gesture gate timeout + + /// Regression for the user-reported live-device bug where pressing and holding the PTT + /// button on the Main tab produced zero `[PTT]` NSLog output, because the legacy + /// `DragGesture(minimumDistance: 0)` on `pttControl` lost gesture arbitration to the + /// parent `TabView` swipe gesture and triggered the iOS + /// `Gesture: System gesture gate timed out.` log. + /// + /// Uses the `--ui-test-route=main-ptt` host, which hosts a stripped-down `PTTButton` + /// wired to on-screen counters (`tacnet.main.pttDebugBegan` / `tacnet.main.pttDebugEnded`) + /// so we can assert exactly-one press-began + exactly-one press-ended delivered from a + /// real `press(forDuration:)` (not a synthetic `tap()`). + func testPTTButtonLongPressDispatchesToViewModel() { + let app = XCUIApplication() + app.launchArguments = ["--ui-test-route=main-ptt"] + app.launch() + + let hostRoot = anyElement(app, identifier: "tacnet.uiTestRoute.mainPTT.root") + waitForExistence(hostRoot, timeout: 8, message: "main-ptt host did not render.") + + let pttButton = anyElement(app, identifier: "tacnet.main.pttButton") + waitForExistence(pttButton, timeout: 6, message: "PTTButton did not render in main-ptt host.") + + // Snapshot counters before interaction — both counters should report 0. + let beganLabel = anyElement(app, identifier: "tacnet.main.pttDebugBegan") + let endedLabel = anyElement(app, identifier: "tacnet.main.pttDebugEnded") + waitForExistence(beganLabel, timeout: 4, message: "pttDebugBegan counter did not render.") + waitForExistence(endedLabel, timeout: 4, message: "pttDebugEnded counter did not render.") + XCTAssertEqual(beganLabel.label, "Began:0", "Began counter should start at 0 before any press.") + XCTAssertEqual(endedLabel.label, "Ended:0", "Ended counter should start at 0 before any press.") + + saveScreenshot(app, named: "ptt-dispatch-before-press") + + // Real long-press (not a synthetic tap()). 1.5s is well past the 0.5s iOS + // long-press default threshold and is the duration called out in the + // validation contract assertion VAL-PTT-003. + pttButton.press(forDuration: 1.5) + + // Wait up to 2s for the counter labels to update to exactly 1. + let beganFiredPredicate = NSPredicate(format: "label == %@", "Began:1") + let endedFiredPredicate = NSPredicate(format: "label == %@", "Ended:1") + let beganExpectation = expectation(for: beganFiredPredicate, evaluatedWith: beganLabel) + let endedExpectation = expectation(for: endedFiredPredicate, evaluatedWith: endedLabel) + let result = XCTWaiter.wait(for: [beganExpectation, endedExpectation], timeout: 2.0) + XCTAssertEqual( + result, + .completed, + "PTTButton press-began / press-ended counters did not both reach 1 within 2s after press(forDuration: 1.5). \(beganLabel.label) | \(endedLabel.label)" + ) + + XCTAssertEqual( + beganLabel.label, + "Began:1", + "press-began handler must fire exactly once per physical press." + ) + XCTAssertEqual( + endedLabel.label, + "Ended:1", + "press-ended handler must fire exactly once per physical release." + ) + + saveScreenshot(app, named: "ptt-dispatch-after-press") + } + + /// VAL-PTT-003 companion: performs a press-hold-release cycle on the PTTButton and + /// asserts that the gesture is NOT swallowed by any ancestor gesture recognizer (which + /// would manifest as the iOS `Gesture: System gesture gate timed out.` console line + /// and dropped / duplicated press events). + /// + /// Because XCUITest runs in an iOS test-runner sandbox that cannot spawn host-side + /// `xcrun simctl spawn booted log stream`, this test uses a behavioral proxy that + /// is strictly equivalent on the SwiftUI gesture layer: two back-to-back `press(forDuration:)` + /// cycles MUST each deliver exactly one press-began + one press-ended event (total + /// Began:2 / Ended:2) with no missed or duplicated events. A gesture-gate timeout would + /// produce a mismatch (0 or 2 on the first, or asymmetric counts), and the real + /// `[PTT] Button press-began` / `[PTT] Button press-ended` NSLog lines emitted by + /// `PTTButtonStyle` remain visible in `Console.app` for on-device verification. + func testPTTButtonGestureDoesNotProduceGestureGateTimeout() { + let app = XCUIApplication() + app.launchArguments = ["--ui-test-route=main-ptt"] + app.launch() + + let pttButton = anyElement(app, identifier: "tacnet.main.pttButton") + waitForExistence(pttButton, timeout: 8, message: "PTTButton did not render.") + let beganLabel = anyElement(app, identifier: "tacnet.main.pttDebugBegan") + let endedLabel = anyElement(app, identifier: "tacnet.main.pttDebugEnded") + waitForExistence(beganLabel, timeout: 4) + waitForExistence(endedLabel, timeout: 4) + XCTAssertEqual(beganLabel.label, "Began:0") + XCTAssertEqual(endedLabel.label, "Ended:0") + + // First press-hold-release cycle — must tick the counters to 1/1. + pttButton.press(forDuration: 1.5) + let firstBegan = expectation( + for: NSPredicate(format: "label == %@", "Began:1"), + evaluatedWith: beganLabel + ) + let firstEnded = expectation( + for: NSPredicate(format: "label == %@", "Ended:1"), + evaluatedWith: endedLabel + ) + XCTAssertEqual( + XCTWaiter.wait(for: [firstBegan, firstEnded], timeout: 2.0), + .completed, + "First press(forDuration:) did not deliver one began + one ended within 2s — gesture likely timed out at the iOS gate. \(beganLabel.label) | \(endedLabel.label)" + ) + saveScreenshot(app, named: "ptt-gate-after-first-press") + + // Second press-hold-release cycle — counters should advance to 2/2. If the iOS + // gesture gate had timed out on the first cycle, the second gesture is typically + // swallowed too; counts would remain at 1/1. + pttButton.press(forDuration: 1.5) + let secondBegan = expectation( + for: NSPredicate(format: "label == %@", "Began:2"), + evaluatedWith: beganLabel + ) + let secondEnded = expectation( + for: NSPredicate(format: "label == %@", "Ended:2"), + evaluatedWith: endedLabel + ) + XCTAssertEqual( + XCTWaiter.wait(for: [secondBegan, secondEnded], timeout: 2.0), + .completed, + "Second press(forDuration:) did not deliver one began + one ended within 2s. \(beganLabel.label) | \(endedLabel.label)" + ) + saveScreenshot(app, named: "ptt-gate-after-second-press") + + XCTAssertEqual( + beganLabel.label, + "Began:2", + "After two press cycles, press-began must fire exactly twice total (once per cycle, no swallowed events)." + ) + XCTAssertEqual( + endedLabel.label, + "Ended:2", + "After two press cycles, press-ended must fire exactly twice total (once per cycle, no swallowed events)." + ) + } + + // MARK: - VAL-PTT-002 / VAL-PTT-003 · Real Main-tab coverage with console-log evidence + + /// VAL-PTT-002 regression on the REAL Main tab (no `--ui-test-route=main-ptt` + /// synthetic host): launches the app through the normal bootstrap, walks the + /// full Welcome → Create Network → Tree Builder → Publish → Role Selection → + /// Claim flow until the live `TabView`/`NavigationStack` Main tab appears, + /// performs a 1.5 s `press(forDuration:)` on the REAL `tacnet.main.pttButton`, + /// and captures the in-process unified log via `UITestLogCapture` (activated + /// via `--ui-test-capture-logs`). Seeds a single fake connected peer with + /// `--ui-test-mesh-peers=1` so the view model takes the happy (connected) + /// path and the `[PTT] Recording started…` line is emitted. + /// + /// Covers the VAL-PTT-002 evidence requirement: captured console log must + /// contain at least one `[PTT]` line AND must NOT contain `Gesture: System + /// gesture gate timed out`. + func testPTTButtonLongPressOnRealMainTabDispatchesToViewModel() { + let app = launchAndReachRealMainTab( + additionalArguments: ["--ui-test-mesh-peers=1", "--ui-test-capture-logs"] + ) + saveScreenshot(app, named: "real-main-tab-before-press") + + let pttButton = anyElement(app, identifier: "tacnet.main.pttButton") + waitForExistence(pttButton, timeout: 8, message: "Real PTTButton did not render on Main tab.") + + // Single 1.5 s press on the real PTTButton — this is the contracted + // `press(forDuration:)` call out of the validation contract. + pttButton.press(forDuration: 1.5) + + let capturedLog = waitForPTTLogBufferToContainPTTLine(app: app, timeout: 4.0) + attachCapturedLog(capturedLog, named: "real-main-tab-single-press.log") + saveScreenshot(app, named: "real-main-tab-after-press") + + assertCapturedLogHasPTTEvidence(capturedLog) + XCTAssertFalse( + capturedLog.contains("Gesture: System gesture gate timed out"), + "Captured log unexpectedly contains the iOS gesture-gate timeout marker:\n\(capturedLog)" + ) + } + + /// VAL-PTT-003 regression on the REAL Main tab: performs two back-to-back + /// `press(forDuration: 1.5)` cycles on the live `tacnet.main.pttButton` hosted + /// inside the real `TabView` + `NavigationStack`, then asserts that the + /// captured in-process log contains ≥2 press-began `[PTT]` lines, ≥2 + /// press-ended `[PTT]` lines, and zero `Gesture: System gesture gate timed + /// out` entries. + /// + /// Uses the disconnected (gated) path deliberately — no + /// `--ui-test-mesh-peers=1` — so the second form of `[PTT]` log line (the + /// `❌ Rejected — disconnected from mesh` gated-path message) is exercised + /// for evidence, complementing the happy-path coverage in + /// `testPTTButtonLongPressOnRealMainTabDispatchesToViewModel`. + func testPTTButtonGestureOnRealMainTabNoGateTimeoutAcrossRepeatedPresses() { + let app = launchAndReachRealMainTab( + additionalArguments: ["--ui-test-capture-logs"] + ) + + let pttButton = anyElement(app, identifier: "tacnet.main.pttButton") + waitForExistence(pttButton, timeout: 8) + + // First cycle. + pttButton.press(forDuration: 1.5) + _ = waitForPTTLogBufferToContainPTTLine(app: app, timeout: 4.0) + saveScreenshot(app, named: "real-main-tab-after-first-press") + + // Second cycle — if the iOS gesture gate had timed out on cycle #1, this + // press typically also gets swallowed, so counters remain unchanged and + // the assertion below fails. + pttButton.press(forDuration: 1.5) + let capturedLog = waitForPTTLogBufferEntryCount( + app: app, + minimumPressBeganLines: 2, + minimumPressEndedLines: 2, + timeout: 4.0 + ) + attachCapturedLog(capturedLog, named: "real-main-tab-repeated-press.log") + saveScreenshot(app, named: "real-main-tab-after-second-press") + + // Validate the captured buffer: at least 2 press-began + at least 2 + // press-ended PTT lines, and zero system-generated gesture-gate timeouts. + let pressBeganCount = countOccurrences( + of: "[PTT] Button press-began", + in: capturedLog + ) + let pressEndedCount = countOccurrences( + of: "[PTT] Button press-ended", + in: capturedLog + ) + XCTAssertGreaterThanOrEqual( + pressBeganCount, + 2, + "Captured log must contain at least 2 press-began `[PTT]` lines for 2 presses. Got:\n\(capturedLog)" + ) + XCTAssertGreaterThanOrEqual( + pressEndedCount, + 2, + "Captured log must contain at least 2 press-ended `[PTT]` lines for 2 presses. Got:\n\(capturedLog)" + ) + XCTAssertFalse( + capturedLog.contains("Gesture: System gesture gate timed out"), + "Captured log unexpectedly contains the iOS gesture-gate timeout marker after 2 presses:\n\(capturedLog)" + ) + } + + // MARK: - Helpers for seeded flow + + /// Launches the app, walks Welcome → Create Network → Tree Builder, adds one child, + /// publishes the network, and returns the app handle at the Role Selection screen. + private func launchAndPublishSeededNetwork() -> XCUIApplication { + let app = makeApp() + app.launch() + + app.buttons["tacnet.welcome.createNetworkButton"].tap() + + let addChildButton = app.buttons["tacnet.treeBuilder.addChildButton"] + waitForExistence(addChildButton, timeout: 8) + + let newChildField = app.textFields["tacnet.treeBuilder.newChildField"] + waitForExistence(newChildField) + newChildField.tap() + newChildField.typeText("Alpha") + addChildButton.tap() + + let publishButton = app.buttons["tacnet.treeBuilder.publishButton"] + waitForExistence(publishButton, timeout: 4) + publishButton.tap() + + return app + } + + /// Boots the app into the REAL Main tab (no synthetic route): walks Welcome → + /// Create Network → Tree Builder (adds Alpha) → Publish → Role Selection → + /// Claim Alpha → TabView with Main tab selected. Returns the live app handle + /// with the real `ContentView.TabView` + `NavigationStack` hierarchy. Any + /// additional launch args (e.g. `--ui-test-mesh-peers=1`, + /// `--ui-test-capture-logs`) are forwarded to the launch. + private func launchAndReachRealMainTab(additionalArguments: [String] = []) -> XCUIApplication { + let app = makeApp(additionalArguments: additionalArguments) + app.launch() + + // Welcome → Create Network. + let createButton = app.buttons["tacnet.welcome.createNetworkButton"] + waitForExistence(createButton, timeout: 10, message: "Welcome did not render on real launch.") + createButton.tap() + + // Tree Builder → add Alpha. + let addChildButton = app.buttons["tacnet.treeBuilder.addChildButton"] + waitForExistence(addChildButton, timeout: 8, message: "Tree Builder did not render.") + let newChildField = app.textFields["tacnet.treeBuilder.newChildField"] + waitForExistence(newChildField) + newChildField.tap() + newChildField.typeText("Alpha") + addChildButton.tap() + + // Publish. + let publishButton = app.buttons["tacnet.treeBuilder.publishButton"] + waitForExistence(publishButton, timeout: 4) + publishButton.tap() + + // Role Selection → claim Alpha so we reach the real Tab shell. + let alphaStaticText = app.staticTexts["Alpha"] + waitForExistence(alphaStaticText, timeout: 8, message: "Role Selection did not render Alpha row.") + let claimableCell = app.buttons + .matching(NSPredicate(format: "identifier BEGINSWITH %@", "tacnet.roleSelection.row.")) + .element(matching: NSPredicate(format: "label CONTAINS %@", "Alpha")) + if claimableCell.exists { + claimableCell.tap() + } else { + alphaStaticText.tap() + } + + // Verify the real Tab shell is live (TabView + Main tab reachable). + let tabBar = app.tabBars.firstMatch + waitForExistence(tabBar, timeout: 12, message: "Real TabView did not appear after Role claim.") + let mainTab = tabBar.buttons["Main"] + if mainTab.exists { + mainTab.tap() + } + let mainRoot = anyElement(app, identifier: "tacnet.main.root") + waitForExistence(mainRoot, timeout: 6, message: "Real Main tab did not render.") + return app + } + + /// Taps the hidden debug-refresh button (installed when `--ui-test-capture-logs` + /// is passed) so the in-app `UITestLogCapture` pulls fresh OSLogStore entries, + /// then returns the current `tacnet.debug.logBuffer` text. Waits up to `timeout` + /// seconds for at least one `[PTT]` line to land in the buffer. + private func waitForPTTLogBufferToContainPTTLine( + app: XCUIApplication, + timeout: TimeInterval + ) -> String { + let refreshButton = app.buttons["tacnet.debug.refreshLogBuffer"] + if refreshButton.exists { + refreshButton.tap() + } + let buffer = anyElement(app, identifier: "tacnet.debug.logBuffer") + waitForExistence(buffer, timeout: 4, message: "PTT debug log buffer did not render.") + + let deadline = Date().addingTimeInterval(timeout) + var lastSnapshot = buffer.label + while Date() < deadline { + lastSnapshot = buffer.label + if lastSnapshot.contains("[PTT]") { + return lastSnapshot + } + if refreshButton.exists { + refreshButton.tap() + } + _ = XCUIApplication().wait(for: .runningForeground, timeout: 0.25) + } + if refreshButton.exists { + refreshButton.tap() + } + return buffer.label + } + + /// Polls the log buffer until at least `minimumPressBeganLines` press-began + /// `[PTT]` lines and `minimumPressEndedLines` press-ended `[PTT]` lines are + /// present, or the timeout elapses. + private func waitForPTTLogBufferEntryCount( + app: XCUIApplication, + minimumPressBeganLines: Int, + minimumPressEndedLines: Int, + timeout: TimeInterval + ) -> String { + let refreshButton = app.buttons["tacnet.debug.refreshLogBuffer"] + let buffer = anyElement(app, identifier: "tacnet.debug.logBuffer") + waitForExistence(buffer, timeout: 4, message: "PTT debug log buffer did not render.") + + let deadline = Date().addingTimeInterval(timeout) + var lastSnapshot = buffer.label + while Date() < deadline { + lastSnapshot = buffer.label + let began = countOccurrences(of: "[PTT] Button press-began", in: lastSnapshot) + let ended = countOccurrences(of: "[PTT] Button press-ended", in: lastSnapshot) + if began >= minimumPressBeganLines, ended >= minimumPressEndedLines { + return lastSnapshot + } + if refreshButton.exists { + refreshButton.tap() + } + _ = XCUIApplication().wait(for: .runningForeground, timeout: 0.25) + } + return buffer.label + } + + private func countOccurrences(of substring: String, in text: String) -> Int { + guard !substring.isEmpty else { return 0 } + var count = 0 + var searchRange = text.startIndex..