From d595e9f4dbc4d250c98a253503954107b7477d05 Mon Sep 17 00:00:00 2001 From: timbogdanov Date: Wed, 19 Aug 2026 13:45:36 -0500 Subject: [PATCH] =?UTF-8?q?Version=200.2.2=20=E2=80=94=20the=20model=20can?= =?UTF-8?q?=20answer,=20and=20one=20file=20per=20platform?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Describe-it had never once worked. Structured outputs compile the schema into a grammar and the service caps a schema at 24 optional parameters; hanging all 29 DSL_RANGES keys plus the nine others off one shape object came to 38 properties with only `type` required. Thirty-seven optional, and every request came back 400 about grammar compilation — a message no user could act on, on a feature that could never have worked for anyone. A shape now carries its parameters as a list of name/value pairs. Nothing in the schema is optional, so the count is zero and stays zero however many primitives the DSL grows, and the name enum still refuses a parameter the DSL does not have. Types and ranges leave the grammar, which costs nothing — every value already went through dslValidateShape's clamping. aiCoerce turns the strings back and names the offender when it cannot ("period is not a number"), and aiToProgram collapses the pairs into ordinary flat shapes, so what lands in the Program box is still a program you can read and edit. The wire format never escapes it. Behind that sat a second failure nobody could reach: "city skyline" came back as "too many layers". The prompt states every parameter range, every primitive, the canvas limits and the scale — and never the layer cap. dslValidate throws the whole program away over it, so the model wrote something reasonable and the user got an error about a limit they were never told. It is stated now, derived from DSL_MAX_LAYERS so the two cannot drift. Releases ship one file per platform: the Apple silicon dmg and the x64 installer. Two dmgs, two zips and three Windows builds meant a person who downloaded twice had PatternFront, PatternFront 2 and PatternFront 3 in Finder with nothing to say which was which — macOS numbering duplicates, exactly as the README warns. The release notes now say to drag to Applications before opening, which is what stops the duplicate existing at all. Held by two new checks that fail on the code as it stood: the schema's optional count against the service's limit of 24, and the prompt naming whatever DSL_MAX_LAYERS currently is. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GRMJpT8yxY2Q33td1QeP7z --- .github/workflows/release.yml | 16 ++++--- app/patternfront.html | 89 ++++++++++++++++++++++++++++------- electron-builder.yml | 16 +++---- package-lock.json | 4 +- package.json | 2 +- tools/verify-behaviour.js | 58 ++++++++++++++++++++++- tools/verify-electron.py | 12 +++-- 7 files changed, 158 insertions(+), 39 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b634f9b..9f8b569 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -102,13 +102,15 @@ jobs: body: | ## Install - **macOS** — download the `.dmg` for your chip (`arm64` for Apple - silicon, `x64` for Intel). These builds are **unsigned**, so the - first launch needs: right-click the app → **Open** → **Open**. - - **Windows** — run the `Setup` installer, or use the portable - `.exe`. SmartScreen will show "Windows protected your PC" → - **More info** → **Run anyway**. + **macOS** (Apple silicon) — open the `.dmg` and drag PatternFront + to Applications, then open it *from there*. These builds are + **unsigned**, so the first launch needs: right-click → **Open** → + **Open**. Opening it from the mounted image instead runs it + translocated from a random read-only path, and the copy left in + Downloads is what Finder later numbers "PatternFront 2". + + **Windows** — run the `Setup` installer. SmartScreen will show + "Windows protected your PC" → **More info** → **Run anyway**. Signing certificates cost money this project does not spend yet. If you would rather not trust an unsigned build, `npm run dist` diff --git a/app/patternfront.html b/app/patternfront.html index ab962aa..e7aad97 100644 --- a/app/patternfront.html +++ b/app/patternfront.html @@ -1723,20 +1723,43 @@ const AI_URL='https://api.anthropic.com/v1/messages'; const AI_MODEL='claude-opus-5'; const AI_KEY='pf.aikey'; -/* The schema is built from the DSL's own tables, so it cannot drift from the - renderer: a primitive or parameter added to DSL_SHAPES/DSL_RANGES appears here - automatically. Every parameter is optional — the model supplies what a shape - needs — but `type` is required, since without it there is no shape at all. */ +/* Which parameters exist, and what a value means once it arrives as text. All + three tables are derived from the DSL's own, so a primitive or parameter added + to DSL_SHAPES/DSL_RANGES is offered to the model automatically. */ +const AI_ENUMS={axis:['h','v'],orientation:['up','down'], + shape:['circle','square','diamond'],font:['5x7','3x5']}; +const AI_BOOLS=['blue']; +const AI_NUMS=()=>[...Object.keys(DSL_RANGES),'seed','angle']; +const AI_PARAMS=()=>[...AI_NUMS(),...AI_BOOLS,...Object.keys(AI_ENUMS),'glyphs']; +/* One shape parameter, as text, turned back into what the renderer expects. + The value arrives as a string because the schema below carries no per-parameter + types; this is where that is paid back, with a message a user can act on rather + than a NaN that clamps to a silent zero. */ +function aiCoerce(name,value){ + if(AI_BOOLS.includes(name)) return value===true||value==='true'; + if(AI_ENUMS[name]){ + if(!AI_ENUMS[name].includes(value)) + throw new Error(name+' must be one of '+AI_ENUMS[name].join(', ')+ + ', not '+JSON.stringify(value)); + return value;} + if(AI_NUMS().includes(name)){ + const n=Number(value); + if(!Number.isFinite(n)) + throw new Error(name+' is not a number: '+JSON.stringify(value)); + return n;} + return String(value); +} +/* Structured outputs compile this schema into a grammar, and the service caps a + schema at 24 OPTIONAL parameters. Hanging all 29 DSL_RANGES keys plus the nine + others off one shape object came to 38 properties with only `type` required — + 37 optional — and every request came back 400 with a message about grammar + compilation. So a shape carries its parameters as a list of name/value pairs + instead: nothing here is optional, the count is zero however many primitives + the DSL grows, and the name enum still refuses a parameter the DSL lacks. + Types and ranges are not in the grammar, which costs nothing — every value + went through aiCoerce and dslValidateShape's clamping regardless. */ function aiSchema(){ - const num={type:'number'},int={type:'integer'},bool={type:'boolean'},str={type:'string'}; - const shapeProps={type:{type:'string',enum:Object.keys(DSL_SHAPES)}, - axis:{type:'string',enum:['h','v']}, - orientation:{type:'string',enum:['up','down']}, - shape:{type:'string',enum:['circle','square','diamond']}, - font:{type:'string',enum:['5x7','3x5']}, - glyphs:str,seed:int,blue:bool,angle:int}; - for(const k of Object.keys(DSL_RANGES)) - shapeProps[k]=(k==='level'||k==='density')?num:int; + const int={type:'integer'},bool={type:'boolean'},str={type:'string'}; return {type:'object',additionalProperties:false, required:['canvas','layers','post'], properties:{ @@ -1746,12 +1769,30 @@ layers:{type:'array',items:{type:'object',additionalProperties:false, required:['op','shape'], properties:{op:{type:'string',enum:['set','union','intersect','xor','subtract']}, - shape:{type:'object',additionalProperties:false,required:['type'], - properties:shapeProps}}}}, + shape:{type:'object',additionalProperties:false, + required:['type','params'], + properties:{ + type:{type:'string',enum:Object.keys(DSL_SHAPES)}, + params:{type:'array',items:{type:'object',additionalProperties:false, + required:['name','value'], + properties:{name:{type:'string',enum:AI_PARAMS()},value:str}}}}}}}}, post:{type:'object',additionalProperties:false, required:['mirrorX','mirrorY','rotate90','invert'], properties:{mirrorX:bool,mirrorY:bool,rotate90:int,invert:bool}}}}; } +/* The reply's pair lists collapsed back into the flat shapes the rest of the + editor speaks, so what lands in the Program box is an ordinary program the + user can read and edit — the wire format never escapes this function. */ +function aiToProgram(raw){ + if(!raw||typeof raw!=='object') throw new Error('program must be an object'); + return {canvas:{...(raw.canvas||{})},post:{...(raw.post||{})}, + layers:(raw.layers||[]).map(l=>{ + const src=(l&&l.shape)||{},shape={type:src.type}; + for(const p of src.params||[]){ + if(!p||typeof p.name!=='string') continue; + shape[p.name]=aiCoerce(p.name,p.value);} + return {op:l&&l.op,shape};})}; +} /* Built from the same tables, for the same reason. */ function aiSystem(){ const ranges=Object.entries(DSL_RANGES) @@ -1766,6 +1807,19 @@ 'Layers compose in order. The first layer is always "set"; later layers use', 'union, intersect, xor or subtract. Give each shape only the parameters that', 'primitive takes.', + 'A program has at most '+DSL_MAX_LAYERS+' layers. That is a hard limit and a', + 'program with more is thrown away whole, so compose the idea within it —', + 'a subject that seems to want more layers wants a simpler reading of itself,', + 'not another layer.', + '', + 'A shape carries its parameters as a list of name/value pairs, and every', + 'value is written as a string — {"type":"diagonal","params":[', + '{"name":"dx","value":"1"},{"name":"period","value":"8"}]}. Omit the pair', + 'entirely rather than inventing a value for a parameter the primitive', + 'does not take.', + 'Values that are not numbers: '+ + Object.entries(AI_ENUMS).map(([k,v])=>k+' '+v.join('/')).join(', ')+ + ', blue true/false, glyphs up to 8 letters or digits.', '', 'Leave canvas.autoSize true unless the user asks for an exact size: the canvas', 'is then sized to a common multiple of every layer\'s period, which is what', @@ -1822,9 +1876,10 @@ if(data.stop_reason==='refusal') throw new Error('The model declined this request'); const text=(data.content||[]).filter(b=>b.type==='text').map(b=>b.text).join(''); if(!text) throw new Error('The model returned nothing to render'); - let prog; - try{ prog=JSON.parse(text); } + let raw; + try{ raw=JSON.parse(text); } catch{ throw new Error('The model did not return a program'); } + const prog=aiToProgram(raw); // pair lists back into flat shapes dslValidate(prog); // throws with a readable reason return prog; } diff --git a/electron-builder.yml b/electron-builder.yml index b24b6e9..444dc20 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -28,11 +28,13 @@ fileAssociations: mac: category: public.app-category.graphics-design icon: build/icon.png + # One artifact, deliberately. Four macOS files (two DMGs, two zips) meant a + # person downloading twice ended up with PatternFront, PatternFront 2 and + # PatternFront 3 sitting in Finder, with no way to tell which was which. + # Apple silicon only: an Intel build is a build nobody here can test. target: - target: dmg - arch: [arm64, x64] - - target: zip - arch: [arm64, x64] + arch: [arm64] # Ad-hoc signed. Not a substitute for a Developer ID — Gatekeeper still # refuses the app on first launch — but it is the difference between a refusal # the user can clear and one they cannot. @@ -80,10 +82,11 @@ dmg: win: icon: build/icon.png + # Likewise one: the installer, x64. The arm64 and universal installers and the + # portable build were three more things to choose between and none of them + # answered a question the plain installer does not. target: - target: nsis - arch: [x64, arm64] - - target: portable arch: [x64] nsis: @@ -94,8 +97,5 @@ nsis: createStartMenuShortcut: true artifactName: ${productName}-Setup-${version}-${arch}.${ext} -portable: - artifactName: ${productName}-${version}-portable.${ext} - # electron-builder would otherwise try to reach out during CI runs. publish: null diff --git a/package-lock.json b/package-lock.json index ef9c15f..bd2adf6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "patternfront", - "version": "0.2.1", + "version": "0.2.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "patternfront", - "version": "0.2.1", + "version": "0.2.2", "license": "MIT", "devDependencies": { "electron": "^43.4.0", diff --git a/package.json b/package.json index 613701c..f690dd8 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "patternfront", "productName": "PatternFront", - "version": "0.2.1", + "version": "0.2.2", "description": "A 1-bit pattern editor for OpenFront territory patterns \u2014 desktop app for macOS and Windows.", "keywords": [ "pixel-art", diff --git a/tools/verify-behaviour.js b/tools/verify-behaviour.js index 7b6884c..7581957 100755 --- a/tools/verify-behaviour.js +++ b/tools/verify-behaviour.js @@ -558,9 +558,12 @@ function duotoneTests() { async function aiTests() { console.log('\n=== writing a program with a model ==='); + // The wire format: parameters as name/value pairs, every value a string. + const pairs = (o) => Object.entries(o).map(([name, value]) => ({ name, value: String(value) })); const GOOD = { canvas: { width: 32, height: 32, scale: 1, autoSize: true }, - layers: [{ op: 'set', shape: { type: 'diagonal', dx: 1, dy: 1, period: 8, thickness: 3 } }], + layers: [{ op: 'set', shape: { type: 'diagonal', + params: pairs({ dx: 1, dy: 1, period: 8, thickness: 3 }) } }], post: { mirrorX: false, mirrorY: false, rotate90: 0, invert: false }, }; const reply = (body, ok = true, status = 200) => async () => ({ @@ -580,6 +583,8 @@ async function aiTests() { grabConst('F57'), grabConst('F35'), grabFunction('dslValidateShape'), grabFunction('dslValidate'), grabConst('AI_URL'), grabConst('AI_MODEL'), grabConst('AI_KEY'), + grabConst('AI_ENUMS'), grabConst('AI_BOOLS'), grabConst('AI_NUMS'), grabConst('AI_PARAMS'), + grabFunction('aiCoerce'), grabFunction('aiToProgram'), grabFunction('aiSchema'), grabFunction('aiSystem'), grabConst('aiKey'), grabFunction('aiWrite'), ].join('\n')); @@ -620,11 +625,60 @@ async function aiTests() { shapeEnum.length === primitiveCount, `${shapeEnum.length} primitives`); chk('the prompt names the primitives too', ctx.aiSystem().includes('diagonal') && ctx.aiSystem().includes('halftone')); + // Every limit dslValidate can throw on has to be in the prompt, or the model + // writes something reasonable and the whole reply is binned. "city skyline" + // came back with more than DSL_MAX_LAYERS layers and the user got "too many + // layers" — the cap was the one bound the prompt never mentioned. + const maxLayers = vm.runInContext('DSL_MAX_LAYERS', ctx); + chk('the prompt states the layer cap the validator enforces', + ctx.aiSystem().includes(`at most ${maxLayers} layers`), + `cap is ${maxLayers}`); + + // Structured outputs compile the schema into a grammar, and the service caps + // a schema at 24 OPTIONAL parameters. The first version of this schema hung + // every DSL parameter off one shape object — 38 properties with only `type` + // required — so all 37 optional ones came back as a 400 that no user could + // act on and the whole feature was dead. Counting them here is what stops a + // new primitive or parameter from quietly doing it again. + const optionals = (node) => { + if (!node || typeof node !== 'object') return 0; + let n = 0; + if (node.properties) { + const req = new Set(node.required || []); + n += Object.keys(node.properties).filter(k => !req.has(k)).length; + for (const v of Object.values(node.properties)) n += optionals(v); + } + if (node.items) n += optionals(node.items); + return n; + }; + const optCount = optionals(schema); + chk('the schema stays under the service\'s optional-parameter limit', + optCount <= 24, `${optCount} optional, limit 24`); // A well-formed reply is validated, not trusted. const prog = await aiWrite('diagonal stripes', reply(asText(GOOD))); chk('a well-formed program comes back validated', prog.layers[0].shape.type === 'diagonal' && prog.layers[0].op === 'set'); + // The pair list is a wire format, not something the renderer or the Program + // box ever sees: what comes back must be an ordinary flat shape, with numbers + // that are numbers. A "8" reaching dslRender would clamp and tile wrong. + const sh = prog.layers[0].shape; + chk('pairs collapse into a flat shape the renderer speaks', + sh.dx === 1 && sh.period === 8 && sh.thickness === 3 && !('params' in sh), + JSON.stringify(sh)); + chk('numeric parameters arrive as numbers, not strings', + typeof sh.period === 'number' && typeof sh.dx === 'number'); + const bad = (name, value) => failed(reply(asText({ + canvas: GOOD.canvas, post: GOOD.post, + layers: [{ op: 'set', shape: { type: 'diagonal', params: [{ name, value }] } }] }))); + chk('a parameter that is not a number is named in the error', + (await bad('period', 'eight')) === 'period is not a number: "eight"', + String(await bad('period', 'eight'))); + chk('a value outside an enumerated set is refused', + (await bad('axis', 'sideways')) === 'axis must be one of h, v, not "sideways"', + String(await bad('axis', 'sideways'))); + chk('booleans survive the crossing', + ctx.aiCoerce('blue', 'true') === true && ctx.aiCoerce('blue', 'false') === false); // Every failure mode, in the order a user would meet them. chk('an unknown shape is rejected, not rendered', @@ -652,6 +706,8 @@ async function aiTests() { $: () => ({ hidden: false, disabled: false, value: '', innerHTML: '' }) }); run(noKey, [grabConst('AI_KEY'), grabConst('aiKey'), grabConst('AI_URL'), grabConst('AI_MODEL'), grabConst('DSL_MIN_W'), grabConst('DSL_SHAPES'), grabConst('DSL_RANGES'), + grabConst('AI_ENUMS'), grabConst('AI_BOOLS'), grabConst('AI_NUMS'), + grabConst('AI_PARAMS'), grabFunction('aiCoerce'), grabFunction('aiToProgram'), grabFunction('aiSchema'), grabFunction('aiSystem'), grabConst('DSL_CANVAS_LOCKED'), grabConst('floorMod'), grabFunction('pyRound'), grabFunction('gcd'), grabFunction('lcm'), grabFunction('hash2'), diff --git a/tools/verify-electron.py b/tools/verify-electron.py index a411099..3e95374 100755 --- a/tools/verify-electron.py +++ b/tools/verify-electron.py @@ -134,9 +134,15 @@ def main() -> int: print("\n=== packaging ===") yml = read_root("electron-builder.yml") - chk("mac builds both architectures", "arch: [arm64, x64]" in yml) - chk("windows gets an installer and a portable build", - "target: nsis" in yml and "target: portable" in yml) + # One file per platform, on purpose. Shipping two DMGs and two zips left + # people with "PatternFront 2" and "PatternFront 3" in Finder — macOS + # numbering duplicate copies — with nothing to say which was which. + chk("macOS ships one artifact: the Apple silicon dmg", + "- target: dmg" in yml and "arch: [arm64]" in yml + and "target: zip" not in yml and "arch: [arm64, x64]" not in yml) + chk("windows ships one artifact: the x64 installer", + "target: nsis" in yml and "arch: [x64]" in yml + and "target: portable" not in yml) chk("only the app ships, not the repo", "- electron/**/*" in yml and "- app/**/*" in yml and "tools/" not in yml) chk("signing is wired but inert", "hardenedRuntime: false" in yml