Skip to content

fix(apple-fm): real on-device generation (programmatic schema) + dlopen packaging so npm arm64 never crashes on macOS <26 - #726

Merged
junhoyeo merged 6 commits into
mainfrom
improve/apple-fm
Jun 21, 2026
Merged

fix(apple-fm): real on-device generation (programmatic schema) + dlopen packaging so npm arm64 never crashes on macOS <26#726
junhoyeo merged 6 commits into
mainfrom
improve/apple-fm

Conversation

@junhoyeo

@junhoyeo junhoyeo commented Jun 18, 2026

Copy link
Copy Markdown
Owner

The bug (in the just-merged #721)

The apple-fm summarizer never actually ran on-device — every session silently fell back to the Rust heuristic (fm_version: null). Found via live validation now that Apple Intelligence is enabled on the build machine.

Root cause: apple_fm.rs passed a plain JSON Schema string to FMLanguageModelSessionRespondWithSchemaFromJSON, but the vendored shim decodes that via JSONDecoder().decode(GenerationSchema.self, …), which expects Apple's private serialized GenerationSchema dialect, not standard JSON Schema. It threw NSCocoaErrorDomain:4865 before generation → callback status 255 → respond_one returned None → heuristic. (This is also why Apple's own shim tests build schemas programmatically via DynamicGenerationSchema, never via the FromJSON string path.)

The fix

Switch to the programmatic schema builder (FMGenerationSchemaCreate / FMGenerationSchemaPropertyCreate / FMGenerationSchemaPropertyAddAnyOfGuide / FMGenerationSchemaAddProperty) + FMLanguageModelSessionRespondWithSchema. Drop SCHEMA_JSON and the FromJSON path. The task_category / complexity enums are now constrained on-device via anyOf guides (with parse_summary/normalize_* still as the post-hoc safety net). Also: log a one-line warning on a non-zero FM status (no more fully-silent fallback), and report.rs now counts FM-generated vs heuristic summaries.

Live proof (macOS 26.2 / arm64, Apple Intelligence on)

cargo test --features apple-fm live_summarize_smoke -- --ignored --nocapture:

ses_live_1: "Enhance API Security with JWT Middleware"  category=feature   complexity=complex   fm_version=apple-fm-on-device
ses_live_2: "Fix null pointer crash on settings page"   category=bugfix    complexity=moderate  fm_version=apple-fm-on-device

Real model-generated titles/categories (not heuristic "Work on "), fm_version now correct. A #[ignore]d live_summarize_smoke test documents this path (skipped in CI).

Verified: default (cross-platform stub) build, --features apple-fm build, clippy, fmt all clean; non-ignored tests pass.

Deferred (left for follow-up — non-blocking)

Sequential per-session generation latency (no batching/progress yet); these are tracked but out of scope here.

🤖 Generated with Claude Code


Summary by cubic

Make apple-fm actually run on-device with a programmatic schema, and load libFoundationModels.dylib at runtime so the prebuilt Apple Silicon npm binary works on all macOS versions and cleanly falls back when Apple Intelligence isn’t available. Docs now note apple-fm ships in the arm64 binary; the report shows FM vs heuristic counts and groups sessions via deterministic title clustering.

  • Bug Fixes

    • Build the output schema programmatically and call FMLanguageModelSessionRespondWithSchema; remove the JSON path that forced heuristic fallback. Enforce enums with anyOf.
    • Load libFoundationModels.dylib via dlopen (macOS ≥ 26 only); if missing/unavailable, degrade to the heuristic. Do not link FM/Swift into the binary. Stage the dylib next to the binary and include it in the npm artifact.
    • Release the +1 retained FMGeneratedContent in the callback to fix one-handle-per-generation leaks.
    • Truncate the first user message to 1000 chars.
    • Log non-zero FM statuses and timeouts; per-session fallback to the heuristic.
    • Don’t persist empty task-group labels from blank titles.
  • New Features

    • Group apple-fm sessions by deterministic title clustering; label groups by the most frequent title.
    • Show per-batch progress for apple-fm (batch size 8).
    • Report prints “M via Apple FM, N heuristic” for apple-fm.

Written for commit 097ea7f. Summary will update on new commits.

Review in cubic

junhoyeo added 2 commits June 18, 2026 22:11
…vs-heuristic provenance

The structured-response callback received a +1-retained FMGeneratedContentRef
on every path (success and error) but only freed the JSON string, leaking one
generated-content wrapper per generation — unbounded in session count. Release
the content handle on both paths.

Also cap first_user_message at 1000 chars (the on-device context window is
small and an oversized pasted message risks truncation/refusal/latency), log
the status/timeout cause when a generation degrades to the heuristic, and print
a "(M via Apple FM, N-M heuristic)" breakdown so a silent total-fallback is
visible. Added an #[ignore]d live smoke test documenting the on-device path and
a module note that fm-c-example's streaming binary segfaults on macOS 26.2.

Constraint: callback owns the content +1 (FMGeneratedContentGetJSONString only borrows it)
Constraint: on-device FM context window is small; prompts must be bounded
Rejected: match the CLI's 200-char cap | FM prompt carries one session, can afford 1000
Rejected: tune SYSTEM_INSTRUCTIONS/build_prompt now | no real FM generations to tune against yet; deferred to maintainer
Confidence: high
Scope-risk: narrow
Directive: the structured_callback must FMRelease(content) on EVERY exit path or it leaks per generation
Not-tested: live on-device generation (live_summarize_smoke stays #[ignore]d in CI)
…ally generates

The summarizer passed a standard JSON-Schema string to
FMLanguageModelSessionRespondWithSchemaFromJSON. The vendored shim decodes
that via JSONDecoder().decode(GenerationSchema.self, ...), which expects
Apple's private serialized GenerationSchema dialect, not JSON Schema, so it
threw NSCocoaErrorDomain:4865 BEFORE generation -> status 255 -> heuristic
fallback 100% of the time (fm_version was always null).

Switch to the programmatic builder (FMGenerationSchemaCreate /
...PropertyCreate / ...AddAnyOfGuide / ...AddProperty) feeding
FMLanguageModelSessionRespondWithSchema, mirroring the path the vendor tests
use (DynamicGenerationSchema). The category/complexity enums are enforced
on-device via unwrapped anyOf guides. Verified live on macOS 26.2 / arm64:
both probe sessions now return fm_version=apple-fm-on-device with real
model-generated titles and correct categories (feature, bugfix).

Constraint: shim maps typeName via 'case "string":' (lowercase) and only accepts unwrapped anyOf guides for scalar String properties
Rejected: keep ...FromJSON with corrected JSON | shim requires Apple's private GenerationSchema dialect, not standard JSON Schema
Confidence: high
Scope-risk: narrow
Directive: typeName MUST stay lowercase 'string' and anyOf wrapped flag MUST stay false; other values fall through to the shim's reference-schema branch / unsupportedGuide and break schema construction
Not-tested: generation exceeding the 60s timeout path
@vercel

vercel Bot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
tokscale Ignored Ignored Preview Jun 20, 2026 11:33pm

Request Review

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 2 files

Re-trigger cubic

…summarize progress

apple-fm (and any non-CLI backend) had no grouping path: run_task_grouping
printed "skipped" and left every task_group null, so print_report_table
collapsed sessions by EXACT title — a degenerate Task Group table full of
near-duplicate one-session rows. Replace the skip with deterministic Rust-side
title clustering: normalize titles to significant tokens (lowercase, strip
punctuation/ellipsis, drop generic verbs/stopwords), greedily cluster by token
overlap (Jaccard >= 0.6 or >= 2 shared tokens), then run a fixpoint
consolidation pass so the result is order-independent. Each cluster is labeled
with its most-frequent original title (tie-broken by shortest).

Also fix apple-fm summarize progress: batch_size was payloads.len() (one giant
chunk) which suppressed the per-batch '\r Batch i/total' indicator. Use a
modest batch size of 8 so 100+ sequential on-device generations show visible
progress; fm-vs-heuristic counts still accumulate across batches.

Constraint: small on-device model — no second array-output LLM call for grouping (fragile); cluster deterministically in Rust instead
Rejected: synthetic token-set keys as group labels | unreadable; use real most-frequent title
Confidence: high
Scope-risk: narrow
Directive: clustering thresholds (Jaccard 0.6 / 2 shared tokens) and CLUSTER_STOPWORDS are tuned for short imperative titles — re-check the unit tests if you change them
Not-tested: behavior when on-device FM emits titles in a non-Latin script (tokenizer is alphanumeric-only)
@junhoyeo

Copy link
Copy Markdown
Owner Author

Added two report improvements on top of the schema fix (commit 368128d7), validated live on real data:

1. Real task grouping for the apple-fm backend. Previously run_task_grouping skipped for apple-fm (only claude/codex/gemini/kiro did LLM grouping), so the Task Group table collapsed by exact title — degenerate (51× identical rows + near-duplicate singletons). Now apple-fm uses a deterministic Rust-side title clustering (significant-token normalization + Jaccard ≥0.6 / ≥2 shared tokens), labeled by the representative title. Live before→after:

Add JWT auth middleware   (8 sessions)
  Add JWT auth middleware
  Enhance JWT auth middleware
  Implement JWT auth middleware
Analyze large session data  (2)
  Analyze large session data
  Analyze large token count session

Near-duplicate titles merge into one group; unrelated ones stay separate. (CLI backends keep their LLM grouping; semantic FM array-output grouping was deliberately avoided — fragile on a small on-device model.) Unit-tested.

2. Summarization progress for apple-fm. batch_size was payloads.len() → the per-batch Batch i/total indicator never fired (109 sequential on-device calls showed nothing until done). Now apple-fm uses batches of 8 so progress is visible; the final ✓ N summarized (X Apple FM, Y heuristic) line and counts are unchanged.

Note: the earlier-suspected --since/--until bug was reproduced and is not real (it correctly returns all entries; the one-off 0 was a cold-start wiki-population artifact).

Verified: default + --features apple-fm builds, clippy, fmt clean; clustering + existing tests pass; live report --week shows merged groups + progress.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 1 file (changes from recent commits).

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread crates/tokscale-cli/src/commands/report.rs
Comment thread crates/tokscale-cli/src/commands/report.rs
junhoyeo added 2 commits June 21, 2026 06:37
…er crashes on macOS <26

The published cli-darwin-arm64 binary ships to EVERY Apple Silicon Mac, but was
built --features apple-fm, hard-linking FoundationModels.framework (macOS 26+)
and macOS-26-SDK Swift runtime (libswiftSynchronization, 15+). otool confirmed
LC_LOAD_DYLIB (not weak), so dyld would abort at launch on macOS 14/15 — a
crash-on-launch for EVERY command (report included), not a feature fallback.
And `import FoundationModels` autolinks the framework as a non-weak load
command, so a build.rs -weak_framework flag can't reliably flip it.

Build the vendored shim as a DYNAMIC libFoundationModels.dylib (Package.swift
already exposes the .dynamic product) and dlopen/dlsym it at runtime instead of
linking. The tokscale binary now links NOTHING FM/Swift — verified:
  otool -L target/release/tokscale  =>  only AppKit/Foundation/CoreGraphics/
  CoreFoundation/libobjc/Security/libiconv/libSystem (all present on macOS 11+).
A sysctl kern.osproductversion >= 26 gate plus graceful dlopen-failure means
older macOS / non-26 silently uses the Rust heuristic; the dylib (and its FM +
Swift deps) only load on macOS 26 where they exist.

Runtime dylib lookup: current_exe()'s dir (npm package + `cargo run`) with a
build-time OUT_DIR fallback baked in for `cargo test` (harness binary lives in
deps/). build.rs stages the dylib next to the binary; CI copies it into the
package bin/ so it travels alongside tokscale.

Verified on macOS 26 (Apple Intelligence on):
- release + debug binaries: zero FM/swift load commands (otool), minos 11.0
- live smoke test: dlopen ok, real FM generation, fm_version=apple-fm-on-device
- npm-equivalent (copied binary in arbitrary dir, run by abs path, ALL fallbacks
  hidden): `dlopen ok .../libFoundationModels.dylib` + 1 via Apple FM
- fmt + clippy (feature on & off) + full suite (678 + 122) green

TOKSCALE_FM_DEBUG=1 traces the OS gate / dlopen path / symbol resolution for
field diagnosis of heuristic fallbacks.

Constraint: one npm binary per (os, cpu) — can't select by macOS version, so the arm64 binary must be safe on every macOS it reaches
Rejected: drop --features apple-fm from publish (npm heuristic-only) | user wants the feature shipped in npm
Rejected: weak_framework + weak Swift runtime | autolink re-adds them as hard loads; not reliably flippable from the Rust side
Rejected: keep static link | hard FoundationModels + libswiftSynchronization => dyld crash-on-launch on macOS <26
Confidence: high
Scope-risk: narrow
Directive: the tokscale binary must keep ZERO FoundationModels/libswift* load commands — re-verify `otool -L` if you touch build.rs linking; a hard link reintroduces the macOS<26 crash
Not-tested: actual macOS 14/15 hardware (no access) — safety rests on the otool load-command invariant, which is the dyld contract
…cOS 26+), heuristic fallback elsewhere

Sync EN/ja/ko/zh: the prebuilt arm64 binary now bundles apple-fm and is safe
to run on every macOS, so the backend note no longer implies a source build.
@junhoyeo junhoyeo changed the title fix(apple-fm): use programmatic GenerationSchema so on-device FM actually generates fix(apple-fm): real on-device generation (programmatic schema) + dlopen packaging so npm arm64 never crashes on macOS <26 Jun 20, 2026
@junhoyeo
junhoyeo merged commit c0d7646 into main Jun 21, 2026
15 checks passed
@junhoyeo
junhoyeo deleted the improve/apple-fm branch June 21, 2026 00:11
junhoyeo added a commit that referenced this pull request Jun 22, 2026
…746)

* docs: sync EN/ja/ko/zh-cn for unreleased clients + breaking flag removal

Pre-v3.2.0 documentation sync for the unreleased range (v3.1.3..main):

- #465: replace the now-false legacy per-client flag notices in ja/ko/zh-cn
  (they claimed the removed flags still work) with a v3.2.0 breaking-change
  migration note; add the same note to README.md (which had none).
- #728: document the MiniMax Token Plan subscription source (distinct from the
  MINIMAX_API_KEY row) in all locales; port the entire Subscription Usage
  section into ja/ko/zh-cn (was English-only).
- #718: add the Jcode table row + detail section to the locales missing them.
- #726: document the TOKSCALE_FM_DEBUG env var in all locales.
- #633: add the missing task-attributed report bullet to README.ja Key Features.
- drift: add Junie to the frontend Source-filtering list (all locales).
- #710: fix the MiMo Code repo link (XiaomiMiMo/MiMo -> XiaomiMiMo/MiMo-Code).
- #717: disclose Command Code token usage is estimated (~4 chars/token).

Confidence: medium
Scope-risk: narrow
Directive: ja/ko/zh-cn translations of the ported Subscription Usage section are machine-generated and should get a native-speaker review pass
Not-tested: #713 Antigravity CLI detail section was not added — no English source section exists to port from

* fix(report): feed real session content to the summarizer (#633)

extract_content_for_session unconditionally returned metadata_only_content()
(first_user_message hardcoded None), so the report summarizer never saw any
conversation content and the four real per-client extractors were dead code.

Add content_extractor::extract_session_content, which dispatches to the correct
per-client extractor (opencode/claude/codex/gemini) and falls back to
metadata-only — never erroring or panicking — for unknown clients, missing
candidates, or unreadable/unparseable files. report.rs builds a SessionPathIndex
once (session_id -> transcript file, plus opencode DBs) and threads it through
run_summarizer so each payload carries the real first user message.

Confidence: high
Scope-risk: moderate
Rejected: thread file paths through core's scanner/WikiEntry | too invasive; indexed at the report layer instead
Not-tested: end-to-end opencode/codex/gemini extraction in report.rs (core dispatcher covers claude + all fallback paths; per-client extractors are pre-existing)

* docs: name the breaking release v4.0.0 (was v3.2.0)

The per-client flag removal (#465) is a breaking change, so the next release is v4.0.0, not v3.2.0. Update the migration notes in all four README locales and the main.rs doc comment accordingly.

* fix(report): real Codex/Gemini extraction + (client,session_id) index keying

Addresses automated review feedback on the #633 report-summarizer-content
fix (PR #746). The summarizer still surfaced (none) for normal Codex/Gemini
sessions and could mis-route cross-client session_id collisions.

- content_extractor: parse the current on-disk Codex format (event_msg with
  payload.type == "user_message", text in payload.message) and skip
  harness-injected context blocks (<environment_context>/<system-reminder>/
  <user_instructions>), mirroring sessions::codex.
- content_extractor: Gemini extractor now handles chat-recording JSON
  (messages[].type == "user" / content) and falls back to scanning
  line-delimited JSONL; empty/whitespace user text is treated as not-found.
- extract_session_content: an empty/whitespace first_user_message no longer
  counts as success, so scanning continues to a later candidate with real text.
- report: SessionPathIndex is keyed by (client, session_id) to prevent
  cross-client collisions, and Gemini files are keyed by their in-file
  sessionId (via gemini_session_id_for_file) rather than the filename stem,
  since the wiki entry's session_id is derived from inside the file.
- Added fixture-based regression tests for all of the above.

Constraint: wiki session_id for Gemini comes from the in-file sessionId, not the path stem
Rejected: match any leading '<' for Codex injected blocks | drops legit prompts starting with markup
Confidence: high
Scope-risk: narrow
pinion05 added a commit to pinion05/tokscale that referenced this pull request Jun 23, 2026
…en packaging so npm arm64 never crashes on macOS <26 (junhoyeo#726)

Two fixes that make the apple-fm `report` summarizer actually work and ship safely:

- Programmatic GenerationSchema: the prior `...RespondWithSchemaFromJSON` path threw NSCocoaErrorDomain:4865 (plain JSON Schema decoded as Apple's private serialized dialect), so every generation silently fell back to the heuristic. Build the schema via FMGenerationSchemaCreate/PropertyCreate/AddAnyOfGuide instead -> FM now generates real titles (fm_version=apple-fm-on-device).
- dlopen instead of linking: FoundationModels.framework (macOS 26+) and the shim's Swift runtime were hard-linked, so the npm arm64 binary would dyld-crash at launch on macOS 14/15. The shim is now built as a dynamic libFoundationModels.dylib and dlopen'd lazily (sysctl >=26 gate + graceful fallback); the binary links nothing FM/Swift (otool-verified). One arm64 binary safe on every macOS; older/non-Apple uses the Rust heuristic.

Also: real task-grouping for apple-fm via title clustering + summarize progress; a guard so blank titles don't persist empty task-group labels; README EN/ja/ko/zh sync. TOKSCALE_FM_DEBUG traces the dlopen path.
t1000040 pushed a commit to tmobi-internal/tokscale that referenced this pull request Jun 30, 2026
…en packaging so npm arm64 never crashes on macOS <26 (junhoyeo#726)

Two fixes that make the apple-fm `report` summarizer actually work and ship safely:

- Programmatic GenerationSchema: the prior `...RespondWithSchemaFromJSON` path threw NSCocoaErrorDomain:4865 (plain JSON Schema decoded as Apple's private serialized dialect), so every generation silently fell back to the heuristic. Build the schema via FMGenerationSchemaCreate/PropertyCreate/AddAnyOfGuide instead -> FM now generates real titles (fm_version=apple-fm-on-device).
- dlopen instead of linking: FoundationModels.framework (macOS 26+) and the shim's Swift runtime were hard-linked, so the npm arm64 binary would dyld-crash at launch on macOS 14/15. The shim is now built as a dynamic libFoundationModels.dylib and dlopen'd lazily (sysctl >=26 gate + graceful fallback); the binary links nothing FM/Swift (otool-verified). One arm64 binary safe on every macOS; older/non-Apple uses the Rust heuristic.

Also: real task-grouping for apple-fm via title clustering + summarize progress; a guard so blank titles don't persist empty task-group labels; README EN/ja/ko/zh sync. TOKSCALE_FM_DEBUG traces the dlopen path.
t1000040 pushed a commit to tmobi-internal/tokscale that referenced this pull request Jun 30, 2026
…unhoyeo#746)

* docs: sync EN/ja/ko/zh-cn for unreleased clients + breaking flag removal

Pre-v3.2.0 documentation sync for the unreleased range (v3.1.3..main):

- junhoyeo#465: replace the now-false legacy per-client flag notices in ja/ko/zh-cn
  (they claimed the removed flags still work) with a v3.2.0 breaking-change
  migration note; add the same note to README.md (which had none).
- junhoyeo#728: document the MiniMax Token Plan subscription source (distinct from the
  MINIMAX_API_KEY row) in all locales; port the entire Subscription Usage
  section into ja/ko/zh-cn (was English-only).
- junhoyeo#718: add the Jcode table row + detail section to the locales missing them.
- junhoyeo#726: document the TOKSCALE_FM_DEBUG env var in all locales.
- junhoyeo#633: add the missing task-attributed report bullet to README.ja Key Features.
- drift: add Junie to the frontend Source-filtering list (all locales).
- junhoyeo#710: fix the MiMo Code repo link (XiaomiMiMo/MiMo -> XiaomiMiMo/MiMo-Code).
- junhoyeo#717: disclose Command Code token usage is estimated (~4 chars/token).

Confidence: medium
Scope-risk: narrow
Directive: ja/ko/zh-cn translations of the ported Subscription Usage section are machine-generated and should get a native-speaker review pass
Not-tested: junhoyeo#713 Antigravity CLI detail section was not added — no English source section exists to port from

* fix(report): feed real session content to the summarizer (junhoyeo#633)

extract_content_for_session unconditionally returned metadata_only_content()
(first_user_message hardcoded None), so the report summarizer never saw any
conversation content and the four real per-client extractors were dead code.

Add content_extractor::extract_session_content, which dispatches to the correct
per-client extractor (opencode/claude/codex/gemini) and falls back to
metadata-only — never erroring or panicking — for unknown clients, missing
candidates, or unreadable/unparseable files. report.rs builds a SessionPathIndex
once (session_id -> transcript file, plus opencode DBs) and threads it through
run_summarizer so each payload carries the real first user message.

Confidence: high
Scope-risk: moderate
Rejected: thread file paths through core's scanner/WikiEntry | too invasive; indexed at the report layer instead
Not-tested: end-to-end opencode/codex/gemini extraction in report.rs (core dispatcher covers claude + all fallback paths; per-client extractors are pre-existing)

* docs: name the breaking release v4.0.0 (was v3.2.0)

The per-client flag removal (junhoyeo#465) is a breaking change, so the next release is v4.0.0, not v3.2.0. Update the migration notes in all four README locales and the main.rs doc comment accordingly.

* fix(report): real Codex/Gemini extraction + (client,session_id) index keying

Addresses automated review feedback on the junhoyeo#633 report-summarizer-content
fix (PR junhoyeo#746). The summarizer still surfaced (none) for normal Codex/Gemini
sessions and could mis-route cross-client session_id collisions.

- content_extractor: parse the current on-disk Codex format (event_msg with
  payload.type == "user_message", text in payload.message) and skip
  harness-injected context blocks (<environment_context>/<system-reminder>/
  <user_instructions>), mirroring sessions::codex.
- content_extractor: Gemini extractor now handles chat-recording JSON
  (messages[].type == "user" / content) and falls back to scanning
  line-delimited JSONL; empty/whitespace user text is treated as not-found.
- extract_session_content: an empty/whitespace first_user_message no longer
  counts as success, so scanning continues to a later candidate with real text.
- report: SessionPathIndex is keyed by (client, session_id) to prevent
  cross-client collisions, and Gemini files are keyed by their in-file
  sessionId (via gemini_session_id_for_file) rather than the filename stem,
  since the wiki entry's session_id is derived from inside the file.
- Added fixture-based regression tests for all of the above.

Constraint: wiki session_id for Gemini comes from the in-file sessionId, not the path stem
Rejected: match any leading '<' for Codex injected blocks | drops legit prompts starting with markup
Confidence: high
Scope-risk: narrow
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant