v1.2.0:8 — tolerate decimal integers in AI output
CI / proof-of-work (Next.js app) (push) Waiting to run
CI / start9/0.4 (StartOS package code) (push) Waiting to run

Local models (Qwen via SparkControl, surfaced on the first SparkControl smoke test) sometimes emit a decimal where the AI-output schema expects an integer — e.g. a half-step "rpe": 7.5 or "reps": 8.0. Zod's .int() rejected these and failed the ENTIRE parse, so one stray decimal killed an otherwise good generation.

Fix: a shared looseInt helper rounds a number to the nearest int before the .int() check, applied to every integer field in both the program and single-workout schemas (rpe, reps, sets, gear, order, durationSeconds, rest/week/day numbers). RPE/reps/sets are stored as integers downstream, so rounding is the correct landing. Transform-before-validate, so inferred types are unchanged.

Parse-only; no schema/data change. 261 tests pass; built + sideloaded to immense-voyage.local (1.2.0:8, clean non-root launch). SparkControl now confirmed working end-to-end.
This commit is contained in:
Keysat
2026-06-19 15:30:06 -05:00
parent 91b5b04d97
commit 794070a1d8
7 changed files with 102 additions and 20 deletions
+5 -3
View File
@@ -116,13 +116,15 @@ Canonical publish path for this project: `~/.proof-of-work/publish.sh` (builds,
## Current state
Latest version is **1.2.0:7****SparkControl AI provider + base-URL footgun fix**. Adds a 6th provider, **SparkControl (local)** — the operator's own self-hosted local-inference gateway. OpenAI-compatible wire format (reuses `generateOpenAIStyle`), **keyless** on the LAN (`requireApiKey:false` → no `Authorization` header), reached over the **internal same-box StartOS address** `http://spark-control.startos:9999/v1` (plain HTTP — no TLS, no cert-skip; the public LAN interface is HTTPS w/ a self-signed cert we deliberately avoid). The Settings form **auto-detects the loaded vLLM model** via SparkControl's `/api/endpoints` (`app/api/ai/sparkcontrol/model`, admin-only + SSRF-guarded), mirroring Ollama auto-detect; $0 in the cost UI. Also fixes a **config footgun** (origin of this session): a custom base URL could ride along to a fixed-URL provider (claude/openai/gemini) whose form field is hidden, get stored, and be **silently ignored** (the provider hits its hardcoded endpoint) — both config write paths (`configs` POST + `[id]` PATCH) now null `baseUrl` for non-custom-URL providers and the form clears it on provider change. **No schema/data change** (`AIConfigProfile.provider` is a free-text column). Details: `docs/guides/ai-subsystem.md` → Provider abstraction (incl. the new "adding a provider" fan-out checklist). **Built + sideloaded** (`immense-voyage.local`, 2026-06-19, `master`) as `proof-of-work_x86_64.s9pk` (80M). tsc clean (app + packaging), lint clean (pre-existing only), **259 tests pass**, `next build` + s9pk build succeed. Registry empty, **publishing parked** (sideload-only via `make install`).
Latest version is **1.2.0:8****decimal-tolerant AI parsing**, a fix-forward on the 1.2.0:7 SparkControl ship. **SparkControl is confirmed working end-to-end on-box** (smoke-tested with `RedHatAI/Qwen3.6-35B-A3B-NVFP4`: connected, streamed 9.7k in / 5.3k out, `$0`/FREE, model auto-detected). The smoke test surfaced one bug: local models emit decimals where the AI schema expected integers (a half-step `"rpe": 7.5`), and zod `.int()` failed the **entire** parse. Fix: a shared `looseInt` (`programSchema.ts`, used by `workoutSchema.ts`) rounds a number to the nearest int **before** the `.int()` check, applied to every integer field in both the program and single-workout schemas (rpe, reps, sets, gear, order, durationSeconds, rest/week/day numbers). Parse-only, types unchanged.
**1.2.0:7 (the SparkControl feature itself):** adds a 6th provider, **SparkControl (local)** — the operator's own self-hosted local-inference gateway. OpenAI-compatible wire format (reuses `generateOpenAIStyle`), **keyless** on the LAN (`requireApiKey:false` → no `Authorization` header), reached over the **internal same-box StartOS address** `http://spark-control.startos:9999/v1` (plain HTTP — no TLS/cert-skip; the public LAN interface is HTTPS w/ a self-signed cert we deliberately avoid). The Settings form **auto-detects the loaded vLLM model** via SparkControl's `/api/endpoints` (`app/api/ai/sparkcontrol/model`, admin-only + SSRF-guarded); $0 in the cost UI. Also fixed a **base-URL footgun**: a custom URL could ride along to a fixed-URL provider (claude/openai/gemini), get stored, and be silently ignored — both config write paths now null `baseUrl` for non-custom-URL providers and the form clears it on provider change. **No schema/data change** (`AIConfigProfile.provider` is free-text). Details: `docs/guides/ai-subsystem.md` → Provider abstraction (incl. the "adding a provider" fan-out checklist). **Built + sideloaded** (`immense-voyage.local`, 2026-06-19, `master`) as `proof-of-work_x86_64.s9pk` (80M). tsc clean (app + packaging), lint clean (pre-existing only), **261 tests pass**, `next build` + s9pk build succeed. Registry empty, **publishing parked** (sideload-only via `make install`).
**Design contract established this session (2026-06-19, committed `7fda9ce`, no UI code changed):** the `design/` folder now holds the durable contract — `DESIGN.md` (9-section brief) + `tokens.tokens.json` (DTCG) + `brand/palette.css` + `inspiration/` provenance. From a Case-B *document-as-is* extract of the as-built dark UI: **monochrome gym-brutalist** (`#0A0A0A` canvas, zinc-only neutral, white primary button, Bebas-uppercase/tracked headings, flat border-based depth), plus two owner calls — **red elevated to the single brand accent `#DC2626`** and a **two-tier radius** (4px controls / 8px containers). AGENTS.md carries the read-before-UI Design line; `ROADMAP.md`**Design** holds the `design-checker` cleanup backlog (gray→zinc, green→emerald, yellow→amber, `rounded-md``rounded`, overlay-only shadows, and a shared `<Button>`). **Read `design/DESIGN.md` before any UI work.**
**Confirmed on-box (2026-06-19, via `start-cli`):** box runs `1.2.0:7`; launched `as nextjs` with no errors, "Ready in 221ms", and (correctly) **no migration ran** — this release adds no column. SparkControl itself is runtime-config (the operator must add a SparkControl config in Settings → AI to exercise it). Recent prior ships (1.2.0 line): **1.2.0:6** AI "today's workout"; **1.2.0:5** Gear replaces RPE for cardio; **1.2.0:4** watts as first-class set field.
**Confirmed on-box (2026-06-19, via `start-cli`):** box runs `1.2.0:8`; launched `as nextjs` with no errors, "Ready in 221ms", and (correctly) **no migration ran** (neither :7 nor :8 adds a column). The operator added a SparkControl config and generated through it successfully (modulo the decimal bug now fixed in :8). Recent prior ships (1.2.0 line): **1.2.0:6** AI "today's workout"; **1.2.0:5** Gear replaces RPE for cardio; **1.2.0:4** watts as first-class set field.
**On-box follow-up (user action):** the live AI config is still the misconfigured one that triggered this session — `provider=gemini` with a LAN `baseUrl` the gemini provider ignores (so it silently hit Google's cloud and returned "Empty response"). The provider can't change on edit, so **delete that config and add a SparkControl one** (Settings → AI → Add config → SparkControl; URL auto-fills to `http://spark-control.startos:9999/v1`, model auto-detects, no key). Known bug (tracked in `ROADMAP.md` → Known bugs): the **1.2.0:2** Safari first-tap retry did NOT fix the mobile-Safari first-login failure — reproduced through 1.2.0:5 (the workout feature didn't touch auth), first tap shows "An unexpected error occurred", second tap works. Diagnosis captured; the fix is gated on one data point — the first failed request's error code from Safari Web Inspector (`-1005` → client delayed-retry; `502`/`503` → Node keep-alive tuning).
**On-box follow-up:** the operator is now on the SparkControl config; the old misconfigured `gemini`+`baseUrl` profile (the empty-response trigger) is no longer active — delete it at leisure (cosmetic). Known bug (tracked in `ROADMAP.md` → Known bugs): the **1.2.0:2** Safari first-tap retry did NOT fix the mobile-Safari first-login failure — reproduced through 1.2.0:5 (the workout feature didn't touch auth), first tap shows "An unexpected error occurred", second tap works. Diagnosis captured; the fix is gated on one data point — the first failed request's error code from Safari Web Inspector (`-1005` → client delayed-retry; `502`/`503` → Node keep-alive tuning).
Working: workout logging, programs (manual + AI), multi-user, curated library, full AI subsystem (6 providers incl. **SparkControl**, multi-config, background generation, single-workout generation + refine, history detail, cost/duration, Ollama + SparkControl auto-detect, infinite-scroll exercise history).
+20 -9
View File
@@ -1,5 +1,16 @@
import { z } from 'zod';
/**
* Models — local ones especially (Qwen via SparkControl, Llama via Ollama) —
* sometimes emit a decimal where we expect an integer (`"rpe": 7.5`,
* `"reps": 8.0`). Round to the nearest int BEFORE the `.int()` check so one
* stray decimal doesn't fail the whole parse. Non-numbers pass through
* untouched, so the outer `.optional()`/`.nullable()` still apply. Shared with
* the single-workout schema (`workoutSchema.ts`).
*/
export const looseInt = (schema: z.ZodNumber) =>
z.preprocess((v) => (typeof v === 'number' ? Math.round(v) : v), schema);
/**
* The shape we ask LLMs to produce, validated server-side via Zod
* after parsing whatever JSON came back. Maps 1:1 onto the existing
@@ -16,12 +27,12 @@ import { z } from 'zod';
export const aiExerciseSchema = z.object({
exerciseId: z.string().nullable(),
exerciseName: z.string().min(1),
order: z.number().int().nonnegative(),
sets: z.number().int().positive().optional().nullable(),
repsMin: z.number().int().positive().optional().nullable(),
repsMax: z.number().int().positive().optional().nullable(),
rpe: z.number().int().min(1).max(10).optional().nullable(),
restSeconds: z.number().int().nonnegative().optional().nullable(),
order: looseInt(z.number().int().nonnegative()),
sets: looseInt(z.number().int().positive()).optional().nullable(),
repsMin: looseInt(z.number().int().positive()).optional().nullable(),
repsMax: looseInt(z.number().int().positive()).optional().nullable(),
rpe: looseInt(z.number().int().min(1).max(10)).optional().nullable(),
restSeconds: looseInt(z.number().int().nonnegative()).optional().nullable(),
/// Suggested starting weight. Not required (cardio, bodyweight,
/// stretching all leave it null). When provided alongside an
/// exerciseId that the user starts a workout from, this seeds the
@@ -34,14 +45,14 @@ export const aiExerciseSchema = z.object({
});
export const aiDaySchema = z.object({
dayOfWeek: z.number().int().min(0).max(6),
dayOfWeek: looseInt(z.number().int().min(0).max(6)),
name: z.string().optional().nullable(),
description: z.string().optional().nullable(),
exercises: z.array(aiExerciseSchema),
});
export const aiWeekSchema = z.object({
weekNumber: z.number().int().positive(),
weekNumber: looseInt(z.number().int().positive()),
phase: z.string().optional().nullable(),
description: z.string().optional().nullable(),
days: z.array(aiDaySchema),
@@ -51,7 +62,7 @@ export const aiProgramSchema = z.object({
name: z.string().min(1),
description: z.string().optional().nullable(),
type: z.string().min(1),
durationWeeks: z.number().int().positive(),
durationWeeks: looseInt(z.number().int().positive()),
weeks: z.array(aiWeekSchema),
});
+7 -7
View File
@@ -1,5 +1,5 @@
import { z } from 'zod';
import { extractJson } from './programSchema';
import { extractJson, looseInt } from './programSchema';
/**
* The shape we ask LLMs to produce for a SINGLE day's workout (the
@@ -24,13 +24,13 @@ import { extractJson } from './programSchema';
export const aiWorkoutExerciseSchema = z.object({
exerciseId: z.string().nullable(),
exerciseName: z.string().min(1),
order: z.number().int().nonnegative(),
order: looseInt(z.number().int().nonnegative()),
/// Number of working sets to pre-fill. Defaults to 3 in the hand-off
/// if the model omits it.
sets: z.number().int().positive().optional().nullable(),
sets: looseInt(z.number().int().positive()).optional().nullable(),
/// Target reps per set (the user overwrites with what they actually
/// did). Omit for time/distance-based work.
reps: z.number().int().positive().optional().nullable(),
reps: looseInt(z.number().int().positive()).optional().nullable(),
/// Suggested working weight. Null for cardio / bodyweight / stretching.
suggestedWeight: z.number().nonnegative().optional().nullable(),
/// "lbs" | "kg". Optional; hand-off falls back to the user's
@@ -38,12 +38,12 @@ export const aiWorkoutExerciseSchema = z.object({
suggestedWeightUnit: z.enum(['lbs', 'kg']).optional().nullable(),
/// Strength effort (1-10). The hand-off keeps this only for non-cardio
/// exercises (cardio uses `gear`).
rpe: z.number().int().min(1).max(10).optional().nullable(),
rpe: looseInt(z.number().int().min(1).max(10)).optional().nullable(),
/// Cardio breathing gear (1-5). The hand-off keeps this only for
/// cardio exercises (strength uses `rpe`).
gear: z.number().int().min(1).max(5).optional().nullable(),
gear: looseInt(z.number().int().min(1).max(5)).optional().nullable(),
/// Target duration in seconds for time-based work (e.g. a hold).
durationSeconds: z.number().int().positive().optional().nullable(),
durationSeconds: looseInt(z.number().int().positive()).optional().nullable(),
notes: z.string().optional().nullable(),
});
@@ -75,6 +75,20 @@ describe('parseAIProgram', () => {
expect(r.ok).toBe(true);
});
it('rounds decimal ints from the model instead of failing (RPE 7.5 -> 8)', () => {
const variant = structuredClone(valid);
const ex = variant.weeks[0].days[0].exercises[0] as Record<string, unknown>;
ex.rpe = 7.5; // -> 8
ex.sets = 4.2; // -> 4
const r = parseAIProgram(JSON.stringify(variant));
expect(r.ok).toBe(true);
if (r.ok) {
const out = r.program.weeks[0].days[0].exercises[0];
expect(out.rpe).toBe(8);
expect(out.sets).toBe(4);
}
});
it('rejects when no JSON found', () => {
const r = parseAIProgram('the model just said hello');
expect(r.ok).toBe(false);
@@ -38,6 +38,24 @@ describe('parseAIWorkout', () => {
}
});
it('rounds decimal ints from the model instead of failing (RPE 7.5 -> 8)', () => {
// Local models (Qwen via SparkControl) sometimes emit half-step RPE or
// other decimals where we expect integers. These must round, not blow up
// the whole parse (the bug that surfaced on the first SparkControl run).
const variant = structuredClone(valid);
const ex = variant.exercises[0] as Record<string, unknown>;
ex.rpe = 7.5; // -> 8
ex.reps = 8.6; // -> 9
ex.sets = 3.4; // -> 3
const r = parseAIWorkout(JSON.stringify(variant));
expect(r.ok).toBe(true);
if (r.ok) {
expect(r.workout.exercises[0].rpe).toBe(8);
expect(r.workout.exercises[0].reps).toBe(9);
expect(r.workout.exercises[0].sets).toBe(3);
}
});
it('accepts null exerciseId for unresolved exercises', () => {
const variant = structuredClone(valid);
variant.exercises[0].exerciseId = null as unknown as string;
+7 -1
View File
@@ -22,6 +22,7 @@ import { v_1_2_0_4 } from './v1.2.0.4'
import { v_1_2_0_5 } from './v1.2.0.5'
import { v_1_2_0_6 } from './v1.2.0.6'
import { v_1_2_0_7 } from './v1.2.0.7'
import { v_1_2_0_8 } from './v1.2.0.8'
/**
* Version graph for the `proof-of-work` package.
@@ -91,9 +92,13 @@ import { v_1_2_0_7 } from './v1.2.0.7'
* internal address, model auto-detected via /api/endpoints. Plus a
* base-URL footgun fix (a custom URL could attach to a fixed-URL
* provider and be silently ignored). No schema/data change.
* v1.2.0:8 — Tolerate decimal integers in AI output: a shared looseInt rounds
* float values (e.g. a half-step RPE 7.5 from a local model) before
* the .int() check, so one stray decimal no longer fails the whole
* generation. Parse-only; no schema/data change.
*/
export const versionGraph = VersionGraph.of({
current: v_1_2_0_7,
current: v_1_2_0_8,
other: [
v_1_0_0_1,
v_1_0_0_2,
@@ -117,5 +122,6 @@ export const versionGraph = VersionGraph.of({
v_1_2_0_4,
v_1_2_0_5,
v_1_2_0_6,
v_1_2_0_7,
],
})
+31
View File
@@ -0,0 +1,31 @@
import { IMPOSSIBLE, VersionInfo } from '@start9labs/start-sdk'
/**
* v1.2.0:8 — Tolerate decimal integers in AI output (2026-06-19).
*
* Local models (notably Qwen via SparkControl, surfaced on the first
* SparkControl smoke test) sometimes emit a decimal where the AI-output
* schema expects an integer — e.g. a half-step `"rpe": 7.5`, or `"reps": 8.0`.
* Zod's `.int()` rejected these and failed the ENTIRE parse ("JSON did not
* match the expected shape"), so a single stray decimal killed an otherwise
* good generation.
*
* Fix: a shared `looseInt` helper rounds a number to the nearest integer
* BEFORE the `.int()` check, applied to every integer field in both the
* program and single-workout schemas (rpe, reps, sets, gear, order,
* durationSeconds, rest/week/day numbers, …). RPE/reps/sets are stored as
* integers downstream, so rounding is the correct landing.
*
* Client-/parse-only — no schema or data change.
*/
export const v_1_2_0_8 = VersionInfo.of({
version: '1.2.0:8',
releaseNotes: {
en_US:
'AI generation is now tolerant of decimal values from local models (e.g. a half-step RPE like 7.5) — they round to the nearest whole number instead of failing the whole generation. No data changes.',
},
migrations: {
up: async () => {},
down: IMPOSSIBLE,
},
})