From 739a42e53aefc7b5c441af24de40e7a2a749afa9 Mon Sep 17 00:00:00 2001 From: dada-yan Date: Mon, 21 Sep 2026 00:45:00 +1000 Subject: [PATCH 1/3] Explore declaration-safe expression editing without changing accepted rule writes Signed-off-by: dada-yan --- scripts/prototypes/expressions/index.html | 31 ++++++++++ scripts/prototypes/expressions/policy.mjs | 59 +++++++++++++++++++ .../prototypes/expressions/policy.test.mjs | 50 ++++++++++++++++ .../expressions/test_declaration_lock.py | 49 +++++++++++++++ 4 files changed, 189 insertions(+) create mode 100644 scripts/prototypes/expressions/index.html create mode 100644 scripts/prototypes/expressions/policy.mjs create mode 100644 scripts/prototypes/expressions/policy.test.mjs create mode 100644 scripts/prototypes/expressions/test_declaration_lock.py diff --git a/scripts/prototypes/expressions/index.html b/scripts/prototypes/expressions/index.html new file mode 100644 index 000000000..bf3ef1609 --- /dev/null +++ b/scripts/prototypes/expressions/index.html @@ -0,0 +1,31 @@ +Expression policy experiment + +

Expression policy experiment

Proposed declaration rules — isolated prototype, no production API writes.

+ + + +


+
+

Last local payload

No writes
+ diff --git a/scripts/prototypes/expressions/policy.mjs b/scripts/prototypes/expressions/policy.mjs new file mode 100644 index 000000000..fe3d999cb --- /dev/null +++ b/scripts/prototypes/expressions/policy.mjs @@ -0,0 +1,59 @@ +// Proposed declaration policy, deliberately outside the production editor/API. +export const MAX_DEPTH = 4; // root=0, checked against authenticated API fixtures +const known = new Set(['USD', 'EUR', 'm', 'kg', 's']); +const numeric = /^[-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?$/; +export function declaration(attribute, allowUnitless = false) { + if (!attribute || attribute.datatype !== 'number') throw Error('numeric_attribute_required'); + if (attribute.unit === '1' && allowUnitless) return '1'; + if (!known.has(attribute.unit)) throw Error('unknown_unit'); + return attribute.unit; +} +export function build(draft, attributes, options = {}, depth = 0) { + if (depth > MAX_DEPTH) throw Error('expression_too_deep'); + if (!draft || draft.kind === 'empty') throw Error('incomplete_expression'); + if (draft.kind === 'attr') { + const attr = attributes.find(a => a.id === draft.id); + return { ast: { attr: draft.id }, unit: declaration(attr, options.allowUnitless), reads: [draft.id] }; + } + if (draft.kind === 'const') { + const text = draft.raw.trim(); + if (!numeric.test(text) || !Number.isFinite(Number(text))) throw Error('finite_number_required'); + return { ast: { const: Number(text) }, unit: '1', reads: [] }; + } + if (draft.kind !== 'arith' || !['add','sub','mul','div'].includes(draft.op)) throw Error('unsupported_expression'); + const l=build(draft.l,attributes,options,depth+1), r=build(draft.r,attributes,options,depth+1); + let unit; + if (['add','sub'].includes(draft.op) && l.unit === r.unit) unit=l.unit; + if (draft.op === 'mul' && (l.unit === '1' || r.unit === '1')) unit=l.unit === '1' ? r.unit : l.unit; + if (draft.op === 'div' && (r.unit === '1' || r.unit === l.unit)) unit=r.unit === '1' ? l.unit : '1'; + if (!unit) throw Error('incompatible_units'); + return { ast: {op:draft.op,l:l.ast,r:r.ast}, unit, reads:[...new Set([...l.reads,...r.reads])] }; +} +export function conclude(draft, target, attributes, options = {}) { + const result=build(draft,attributes,options); + if (!result.reads.length) throw Error('constant_expression'); + if (declaration(attributes.find(a => a.id===target),options.allowUnitless)!==result.unit) throw Error('target_unit_mismatch'); + return result.ast; +} +export function reopen(ast, depth=0) { + if (!ast || typeof ast !== 'object' || Array.isArray(ast) || depth>MAX_DEPTH) return null; + const keys=Object.keys(ast).sort().join(','); + if (keys==='attr' && typeof ast.attr==='string') return {kind:'attr',id:ast.attr}; + // Legacy string constants stay metadata-only; no silent conversion on reopen. + if (keys==='const' && typeof ast.const==='number' && Number.isFinite(ast.const)) return {kind:'const',raw:String(ast.const)}; + if (keys!=='l,op,r' || !['add','sub','mul','div'].includes(ast.op)) return null; + const l=reopen(ast.l,depth+1),r=reopen(ast.r,depth+1); + return l && r ? {kind:'arith',op:ast.op,l,r} : null; +} +export function preview(ast, attributes) { + if ('attr' in ast) { const a=attributes.find(a=>a.id===ast.attr); return a ? `${a.label} [${a.key}]` : ast.attr; } + if ('const' in ast) return String(ast.const); + return `(${preview(ast.l,attributes)} ${{add:'+',sub:'−',mul:'×',div:'÷'}[ast.op]} ${preview(ast.r,attributes)})`; +} +export function patch(original, metadata, definition) { + const result={name:metadata.name,description:metadata.description}; + // The editor only supplies definition after explicit opt-in. Group identities + // and UUIDs are copied as a whole, never rebuilt from labels or flattened. + if (definition && JSON.stringify(definition)!==JSON.stringify(original)) Object.assign(result,structuredClone(definition)); + return result; +} diff --git a/scripts/prototypes/expressions/policy.test.mjs b/scripts/prototypes/expressions/policy.test.mjs new file mode 100644 index 000000000..00a6f8d1a --- /dev/null +++ b/scripts/prototypes/expressions/policy.test.mjs @@ -0,0 +1,50 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {build,conclude,reopen,preview,patch,declaration} from './policy.mjs'; +const a=[{id:'r',key:'revenue',label:'Amount',datatype:'number',unit:'USD'},{id:'c',key:'cost',label:'Amount',datatype:'number',unit:'USD'},{id:'e',key:'euro',label:'Amount',datatype:'number',unit:'EUR'},{id:'ratio',key:'ratio',label:'Ratio',datatype:'number',unit:'1'}]; +const attr=id=>({kind:'attr',id}), num=raw=>({kind:'const',raw}), op=(op,l,r)=>({kind:'arith',op,l,r}); +const margin=op('sub',attr('r'),attr('c')); +test('same-unit subtraction and dimensionless scaling preserve the tree',()=>{ + const tree=op('mul',margin,num('1.1')); const ast=conclude(tree,'r',a); + assert.deepEqual(ast,{op:'mul',l:{op:'sub',l:{attr:'r'},r:{attr:'c'}},r:{const:1.1}}); + assert.deepEqual(conclude(reopen(ast),'r',a),ast); +}); +test('unknown, missing, offset, ambiguous and compound units are not unitless',()=>{ + for(const unit of [null,undefined,'','1','$','¥','%','bp','°C','USD/year']) assert.throws(()=>declaration({...a[0],unit})); + assert.equal(declaration(a[3],true),'1'); +}); +test('mismatched add/sub, dimensioned multiplication and bare subtract are rejected',()=>{ + for(const tree of [op('add',attr('r'),attr('e')),op('sub',attr('r'),num('1')),op('mul',attr('r'),attr('c')),op('div',attr('r'),attr('e'))]) assert.throws(()=>build(tree,a),/incompatible_units/); +}); +test('ratio requires separately approved explicit-unitless target',()=>{ + const tree=op('div',margin,attr('r')); + assert.throws(()=>conclude(tree,'ratio',a),/unknown_unit/); + assert.deepEqual(conclude(tree,'ratio',a,{allowUnitless:true}),{op:'div',l:{op:'sub',l:{attr:'r'},r:{attr:'c'}},r:{attr:'r'}}); + assert.throws(()=>conclude(tree,'r',a,{allowUnitless:true}),/target_unit_mismatch/); +}); +test('draft fragments, non-finite and non-decimal strings never become zero',()=>{ + for(const raw of ['', ' ','-','1e','NaN','Infinity','0x10','1e999']) assert.throws(()=>build(num(raw),a)); + for(const raw of ['0','-2','.5','1e2']) assert.equal(build(num(raw),a).ast.const,Number(raw)); +}); +test('missing and nonnumeric attributes reject; labels never identify nodes',()=>{ + assert.throws(()=>build(attr('missing'),a)); + assert.throws(()=>build(attr('r'),[{...a[0],datatype:'text'}])); + assert.match(preview(build(margin,a).ast,a),/revenue.*cost/); + const renamed=a.map(x=>({...x,label:'Renamed'})); + assert.deepEqual(build(margin,a).ast,build(margin,renamed).ast); +}); +test('depth boundary is four edges and five nodes, next edge rejects',()=>{ + let tree=attr('r'); for(let i=0;i<4;i++)tree=op('mul',tree,num('1')); + assert.ok(build(tree,a)); assert.throws(()=>build(op('mul',tree,num('1')),a),/expression_too_deep/); +}); +test('unknown and mixed legacy shapes remain protected',()=>{ + for(const ast of [{attr:'r',extra:true},{const:'2'},{op:'pow',l:{attr:'r'},r:{const:2}},{attr:'r',const:2}]) assert.equal(reopen(ast),null); +}); +test('metadata-only patch does not rewrite a legacy definition or condition groups',()=>{ + const original={conclude_expr:{future:'unknown'},conditions:[{group:2},{group:7}]}; + assert.deepEqual(patch(original,{name:'New',description:'D'}),{name:'New',description:'D'}); + assert.deepEqual(patch(original,{name:'New',description:'D'},original),{name:'New',description:'D'}); + const changed={...original,conclude_expr:{attr:'r'}}; + assert.deepEqual(patch(original,{name:'New',description:'D'},changed).conditions,[{group:2},{group:7}]); +}); +test('constant-only computed rules stay rejected',()=>assert.throws(()=>conclude(num('4'),'r',a),/constant_expression/)); diff --git a/scripts/prototypes/expressions/test_declaration_lock.py b/scripts/prototypes/expressions/test_declaration_lock.py new file mode 100644 index 000000000..22af144d4 --- /dev/null +++ b/scripts/prototypes/expressions/test_declaration_lock.py @@ -0,0 +1,49 @@ +"""Proposed write-time lock boundary on PostgreSQL; not production API validation.""" +import concurrent.futures +import os +import time +import unittest +import uuid +import psycopg +from psycopg import sql + +class DeclarationLock(unittest.TestCase): + def test_share_lock_keeps_unit_stable_through_definition_commit(self): + schema='unit_model_'+uuid.uuid4().hex + dsn=os.environ['UTOPIA_DATABASE_URL'] + with psycopg.connect(dsn,autocommit=True) as admin: + admin.execute(sql.SQL('CREATE SCHEMA {}').format(sql.Identifier(schema))) + admin.execute(sql.SQL('CREATE TABLE {}.attribute(id int PRIMARY KEY, unit text); INSERT INTO {}.attribute VALUES(1,\'USD\')').format(sql.Identifier(schema),sql.Identifier(schema))) + def connection(): + c=psycopg.connect(dsn) + c.execute(sql.SQL('SET search_path TO {}').format(sql.Identifier(schema))) + return c + try: + with connection() as writer: + pid=writer.execute('SELECT pg_backend_pid()').fetchone()[0] + self.assertEqual(writer.execute('SELECT unit FROM attribute WHERE id=1 FOR SHARE').fetchone()[0],'USD') + def update(): + with connection() as updater: + updater.execute("UPDATE attribute SET unit='EUR' WHERE id=1") + with concurrent.futures.ThreadPoolExecutor(1) as ex: + future=ex.submit(update) + try: + deadline=time.monotonic()+5 + while time.monotonic() Date: Mon, 21 Sep 2026 00:51:18 +1000 Subject: [PATCH 2/3] Link the proposed expression policy to its executable evidence Signed-off-by: dada-yan --- .../0032-a-rule-computes-what-it-concludes.md | 7 ++ scripts/prototypes/expressions/README.md | 87 +++++++++++++++++++ 2 files changed, 94 insertions(+) create mode 100644 scripts/prototypes/expressions/README.md diff --git a/docs/decisions/0032-a-rule-computes-what-it-concludes.md b/docs/decisions/0032-a-rule-computes-what-it-concludes.md index e029ac71b..c10798d14 100644 --- a/docs/decisions/0032-a-rule-computes-what-it-concludes.md +++ b/docs/decisions/0032-a-rule-computes-what-it-concludes.md @@ -66,3 +66,10 @@ If it is ever wanted it needs its own record, answering what a completeness clai - **How deep before the picker loses.** Stated above as a concession, not settled. One operator is certainly a picker; nobody has yet said what they need beyond that. - **Does a computed conclusion feed the next rule?** It should — [0030](0030-a-rule-may-read-what-a-rule-concluded.md) puts a concluded value back in the fact pool and says nothing about how the value was arrived at. Worth a test rather than an assumption. - **Rounding and display.** A ratio of two readings is a long decimal. What the ledger stores and what the panel shows are not necessarily the same, and neither is decided here. + +## Proposed revision · 2026-09-21 (not accepted or implemented) + +An isolated protocol experiment and the exact decisions requested are recorded in +[the experiment report](../../scripts/prototypes/expressions/README.md). +This proposal does not change the accepted decisions or implementation status above. +It must be reviewed before enabling the corresponding production write/sender path. diff --git a/scripts/prototypes/expressions/README.md b/scripts/prototypes/expressions/README.md new file mode 100644 index 000000000..36a8847e1 --- /dev/null +++ b/scripts/prototypes/expressions/README.md @@ -0,0 +1,87 @@ +# Proposed declaration policy for the expression picker + +Status: **proposed; no production validation or editor changes**. Refs #488. +ADR 0032 requires unit/datatype checks, but does not define what a missing unit +means or authorize implicit conversion. The separate expression-operand API fix +restores an already described capability; this proposal deliberately does not +silently tighten that API's accepted computed definitions. + +## Decision requested + +Approve a conservative write-time declaration subset: numeric `number` attributes, +exact known units, no conversion, and missing/ambiguous units as **unknown** rather +than dimensionless. Decide whether the exact string `1` is the explicit unitless +representation. Prefer enabling same-unit addition/subtraction and numeric factor +scaling first; ratios need the explicit-unitless decision for their target. + +The executable policy uses USD/EUR/m/kg/s as experimental known declarations, not +an exhaustive units language. It treats $, ¥, %, basis points, Celsius and arbitrary +compound strings as unknown. The accepted allowlist must be agreed alongside `1`; +it must not infer aliases from labels, backfill empty units, or claim that matching +declarations normalize historic observations. + +| Operation | Proposed accepted inputs | Result | +|---|---|---| +| add/subtract | equal known units, or both explicitly unitless | same unit | +| multiply | at least one unitless | other operand's unit | +| divide | unitless denominator, or identical known units | numerator, or unitless | +| constant | finite decimal number | unitless factor | + +A bare `revenue(USD)-1` is rejected. A scalar legacy threshold remains governed by +its existing API contract; this policy applies to editing expressions, not a rewrite +of all stored conditions. Changing declarations later can invalidate assumptions: +this is a write-time check, not a new ontology lifecycle/revision system. + +## Transaction boundary proposed for the API + +Resolve references in the current KB, sort their UUIDs, read/lock the relevant +attribute declarations with `FOR SHARE`, validate, then write the rule in the same +short transaction. `FOR KEY SHARE` is insufficient for concurrent datatype/unit +updates. Metadata-only PATCH and existing enabled toggles do not rewrite/revalidate +legacy definitions. Deletion/foreign-base/permission checks remain server-owned. + +The PostgreSQL experiment observes a real blocked declaration UPDATE via +`pg_blocking_pids`; the writer continues to read USD until commit, after which a +subsequent validator sees EUR. This establishes the proposed lock primitive, **not** +that production rule routes already perform it. Keep this separate from A0. + +## Editor experiment and evidence + +`scripts/prototypes/expressions/` holds a standalone picker and policy module: + +```sh +node --test scripts/prototypes/expressions/policy.test.mjs +python -m http.server 5190 --bind 127.0.0.1 --directory scripts/prototypes/expressions +# Open http://127.0.0.1:5190; all saves are local model state, never API requests. +``` + +For the optional DB lock experiment, install psycopg[binary]==3.2.10 in an isolated +venv and run `python -m unittest -v test_declaration_lock` from that directory with +UTOPIA_DATABASE_URL pointing to an isolated PG16 test database. It creates/drops a +random `unit_model_*` schema and releases the writer even on assertion failure. + +Linux: **10 Node tests and 1 PostgreSQL lock experiment passed**. Tests cover the +operation table, unknown versus unitless, nonnumeric/missing attributes, non-finite +and unfinished constants, UUID-independent label changes, grouped-condition +preservation, metadata-only patches, mixed/unknown legacy shapes, constant-only +rejection and depth. Root depth is zero: four edges/five nodes is accepted by the +API and parser; the independent Expr::depth method uses leaf depth one. + +A headless Chromium interaction check passed local save/reopen, invalid constant, +failed-save draft retention/retry, and unknown-definition metadata-only save. This +is **not real-backend E2E**, is not the final localized/accessibility-reviewed UI, +and does not replace B1. The toy attribute identifiers illustrate tree identity; +production UUID and same-KB validation remain the API's responsibility. + +## Implementation after approval + +Add server declaration validation in the short write transaction, then integrate +structured drafts into RulesPanel using the existing expression display and protected +metadata editor. Keep grouping, explicit scalar/expression modes, failed-save drafts, +KB switching, UUID selection and exact tree order. Reuse known/unknown shape checks; +never strip unknown keys to make a definition editable. Preview and save must use +the same validated AST. Add actual browser/API create-read-edit-read, concurrent +declaration updates and inference/premise/interval regressions before enabling it. + +Rollback of UI retains B1. It does not delete existing rules. No new AST shapes, +relation paths, aggregate operators, formula runtime or unit conversion are proposed. From 64206696689f1901f5cfd3420553a26d70b8de3a Mon Sep 17 00:00:00 2001 From: dada-yan Date: Mon, 21 Sep 2026 11:47:10 +1000 Subject: [PATCH 3/3] Explore expression drafts in the real web interface and record declaration decisions. Signed-off-by: dada-yan --- .../0032-a-rule-computes-what-it-concludes.md | 10 +- ...ions-are-checked-when-a-rule-is-written.md | 93 +++++++++++++++++++ docs/decisions/README.md | 2 + scripts/prototypes/expressions/README.md | 87 ----------------- scripts/prototypes/expressions/index.html | 31 ------- scripts/prototypes/expressions/policy.mjs | 59 ------------ .../prototypes/expressions/policy.test.mjs | 50 ---------- .../expressions/test_declaration_lock.py | 49 ---------- web/src/i18n/en.ts | 30 ++++++ web/src/i18n/zh.ts | 30 ++++++ web/src/pages/ExpressionDraftEditor.tsx | 52 +++++++++++ web/src/pages/ExpressionDraftLab.tsx | 58 ++++++++++++ web/src/pages/expressionDraft.test.ts | 36 +++++++ web/src/pages/expressionDraft.ts | 22 +++++ web/src/router.tsx | 9 ++ 15 files changed, 335 insertions(+), 283 deletions(-) create mode 100644 docs/decisions/0049-expression-declarations-are-checked-when-a-rule-is-written.md delete mode 100644 scripts/prototypes/expressions/README.md delete mode 100644 scripts/prototypes/expressions/index.html delete mode 100644 scripts/prototypes/expressions/policy.mjs delete mode 100644 scripts/prototypes/expressions/policy.test.mjs delete mode 100644 scripts/prototypes/expressions/test_declaration_lock.py create mode 100644 web/src/pages/ExpressionDraftEditor.tsx create mode 100644 web/src/pages/ExpressionDraftLab.tsx create mode 100644 web/src/pages/expressionDraft.test.ts create mode 100644 web/src/pages/expressionDraft.ts diff --git a/docs/decisions/0032-a-rule-computes-what-it-concludes.md b/docs/decisions/0032-a-rule-computes-what-it-concludes.md index c10798d14..c356d2835 100644 --- a/docs/decisions/0032-a-rule-computes-what-it-concludes.md +++ b/docs/decisions/0032-a-rule-computes-what-it-concludes.md @@ -59,6 +59,9 @@ If it is ever wanted it needs its own record, answering what a completeness clai **Units and datatypes have to be checked when the expression is written, and today nothing checks them.** `relation_types` carries `unit` and `datatype` and no code compares them. `revenue (USD) − cost (EUR)` must be refused by the picker, not silently subtracted; the result's type has to match the concluded predicate's. This is new work that the constant case never needed. +**Revision proposed 2026-09-21:** [0049](0049-expression-declarations-are-checked-when-a-rule-is-written.md) answers the missing declaration semantics and write-time locking question below. Missing units are not assumed unitless; exact `1`, the allowlist and first-cut operations remain proposals. The accepted expression semantics and metadata-only fallback are unchanged. + + **A missing reading is not a zero, and neither is a division by zero.** If any attribute in the expression has no reading on the interval, the expression has no value and nothing is concluded — consistent with 0029. Division by zero is the same: no conclusion, **reported** the way `capped` is, because "not computed here" and "the criterion was not met" look identical in the result otherwise. ## Open @@ -66,10 +69,3 @@ If it is ever wanted it needs its own record, answering what a completeness clai - **How deep before the picker loses.** Stated above as a concession, not settled. One operator is certainly a picker; nobody has yet said what they need beyond that. - **Does a computed conclusion feed the next rule?** It should — [0030](0030-a-rule-may-read-what-a-rule-concluded.md) puts a concluded value back in the fact pool and says nothing about how the value was arrived at. Worth a test rather than an assumption. - **Rounding and display.** A ratio of two readings is a long decimal. What the ledger stores and what the panel shows are not necessarily the same, and neither is decided here. - -## Proposed revision · 2026-09-21 (not accepted or implemented) - -An isolated protocol experiment and the exact decisions requested are recorded in -[the experiment report](../../scripts/prototypes/expressions/README.md). -This proposal does not change the accepted decisions or implementation status above. -It must be reviewed before enabling the corresponding production write/sender path. diff --git a/docs/decisions/0049-expression-declarations-are-checked-when-a-rule-is-written.md b/docs/decisions/0049-expression-declarations-are-checked-when-a-rule-is-written.md new file mode 100644 index 000000000..30cc060bd --- /dev/null +++ b/docs/decisions/0049-expression-declarations-are-checked-when-a-rule-is-written.md @@ -0,0 +1,93 @@ +# 0049 · Expression declarations are checked when a rule is written + +- **Status**: proposed; domain contract pending review. The opt-in web draft does not validate units or save rules. +- **Written**: 2026-09-21 +- **Related**: [0032](0032-a-rule-computes-what-it-concludes.md); [PR #839](https://github.com/deeplethe/utopia/pull/839). + +## Problem + +0032 asks for datatype and unit checks but does not say whether absent declarations are dimensionless. A rule can otherwise subtract revenue declared in USD from cost declared in EUR and present a meaningless result. The existing expression API and metadata-only editor remain compatible; this proposal must not silently tighten their write contract. + +## Decision requested + +Approve a conservative write-time declaration subset: numeric `number` attributes, +exact known units, no conversion, and missing/ambiguous units as **unknown** rather +than dimensionless. Decide whether the exact string `1` is the explicit unitless +representation. Prefer enabling same-unit addition/subtraction and numeric factor +scaling first; ratios need the explicit-unitless decision for their target. + +The historical policy model used USD/EUR/m/kg/s as experimental known declarations, not +an exhaustive units language. It treats $, ¥, %, basis points, Celsius and arbitrary +compound strings as unknown. The accepted allowlist must be agreed alongside `1`; +it must not infer aliases from labels, backfill empty units, or claim that matching +declarations normalize historic observations. + +| Operation | Proposed accepted inputs | Result | +|---|---|---| +| add/subtract | equal known units, or both explicitly unitless | same unit | +| multiply | at least one unitless | other operand's unit | +| divide | unitless denominator, or identical known units | numerator, or unitless | +| constant | finite decimal number | unitless factor | + +A bare `revenue(USD)-1` is rejected. A scalar legacy threshold remains governed by +its existing API contract; this policy applies to editing expressions, not a rewrite +of all stored conditions. Changing declarations later can invalidate assumptions: +this is a write-time check, not a new ontology lifecycle/revision system. + +## Transaction boundary proposed for the API + +Resolve references in the current KB, sort their UUIDs, read/lock the relevant +attribute declarations with `FOR SHARE`, validate, then write the rule in the same +short transaction. `FOR KEY SHARE` is insufficient for concurrent datatype/unit +updates. Metadata-only PATCH and existing enabled toggles do not rewrite/revalidate +legacy definitions. Deletion/foreign-base/permission checks remain server-owned. + +The PostgreSQL experiment observes a real blocked declaration UPDATE via +`pg_blocking_pids`; the writer continues to read USD until commit, after which a +subsequent validator sees EUR. This establishes the proposed lock primitive, **not** +that production rule routes already perform it. Keep this separate from A0. + +## Investigation and its limits + +Historical evidence at `30a0da8ca06ce19325432cfc6be0a3cbfecb642d` on Linux, Node 22.23.2 and PostgreSQL 16.15: ten Node model tests and one isolated PostgreSQL lock experiment passed. The lock probe observed `pg_blocking_pids` for a concurrent **non-key** datatype/unit update: `FOR SHARE` blocked it until commit; `FOR KEY SHARE` did not. This establishes a lock primitive, not production route validation. + +The historical standalone browser checked local model save/reopen, invalid constants and failed-save retention. It did not use Utopia APIs and supplies no evidence about a real picker at scale. Its scripts and page have been archived outside the repository, not translated into another executable policy. Model results are not tests of a production unit validator. + +The unlisted `/kb/$kbId/expression-draft` route opts into the exploration in `web/`. It uses structured drafts and the existing UI controls with authenticated attributes and rules from the current knowledge base. Draft previews do not persist anything. Attribute declarations are displayed, not interpreted as an approved unit language. The depth question remains a usability decision: automated interaction checks can establish structure, search and focus behavior, but cannot supply a person's tolerance for nested editing. + +## Alternatives and remaining decisions + +Treating an empty unit as unitless would silently accept undeclared quantities. Inferring aliases or converting observations would introduce a separate normalization contract. A formula string would create a second representation beside the AST. Prefer the explicit three-state declaration model: known, explicitly unitless, unknown. The exact `1` spelling, known-unit allowlist, and whether to enable ratios in the first cut still need approval. USD/EUR/m/kg/s are investigation samples, not a shipped allowlist. + +## Implementation after approval + +Add server declaration validation in the short write transaction, then integrate +structured drafts into RulesPanel using the existing expression display and protected +metadata editor. Keep grouping, explicit scalar/expression modes, failed-save drafts, +KB switching, UUID selection and exact tree order. Reuse known/unknown shape checks; +never strip unknown keys to make a definition editable. Preview and save must use +the same validated AST. Add actual browser/API create-read-edit-read, concurrent +declaration updates and inference/premise/interval regressions before enabling it. + +Rollback of UI retains B1. It does not delete existing rules. No new AST shapes, +relation paths, aggregate operators, formula runtime or unit conversion are proposed. + +## Picker observations in this revision + +Authenticated API-created bases with 30, 300 and 1000 attributes were read in full +(the ontology endpoint uses `fetch_all`, without pagination or a list cap). The +fixtures include duplicate labels, Chinese and English long labels, mixed declared +datatypes, and absent units. Browser checks built revenue minus cost and margin, +reopened real right-nested subtraction/division rules, edited an inner operand, +and retained invalid numeric text without manufacturing a value. Search reaches +the thousandth attribute by key, with keyboard selection and focus returning to +the picker. The four-edge bound keeps leaves editable; it does not flatten the tree. + +At a 390 px viewport the editor remains within its container, including a depth-four +leaf. Nesting nevertheless makes a long vertical form: reaching an inner operand +requires scrolling. These are mechanical observations from automated Chrome, not a +human usability score or a decision that four levels are pleasant. Comparing with a +formula language remains outside this change. HTTP-read failure/retry and unknown +expression shape were checked using explicitly injected browser responses; they +are not claims that the backend accepted a future definition. No draft save route +is enabled, and the existing B1 metadata editor and dependency view are unchanged. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 6fc359926..602d6d96b 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -73,6 +73,7 @@ The test for writing one: if someone (including us) looks at a piece of code in | 0045 | [A time mention is resolved against its document](0045-a-time-mention-is-resolved-against-its-document.md) | Accepted · cuts 1 and 2 built (#740): a document is dated from its own text, each mention is interpreted by the model and computed by code, upload time is used nowhere · cuts 3 and 4 (grades replace the confidence gate, re-resolution and the anchor queue) not built · a time expression is a mention with its words and place; the model returns shape, anchor, offset and granularity and code computes the interval; a document carries its own date, calendars and anchors across chunks, never its upload time; unresolved mentions wait for an anchor; timelines close on resolution grade instead of confidence | | 0046 | [The app surface is MCP](0046-the-app-surface-is-mcp.md) | Decided, with the refused design kept. Asked for an app center: applications built on this knowledge, mounted, run in a sandbox, handed to a team. The answer is that the surface already exists — a personal token carries identity and scope, ten read tools serve chat and MCP from one place, `as_of` reaches every graph read, and a read returns `structuredContent` with stable ledger identities — so a coding agent builds on this base today in its own platform, its own language and its own sandbox. Refused here because the layer an app would read is being replaced under it (typed facts now come only from alignment), because 0016 closes open seams before cutting new ones, and because a catalog, an execution boundary and quotas are three other products. The shape is kept with the four gates it would have to hold (runs as the caller, egress only through a declared action, a declared clock, the existing queue) and the dead ends: a container runtime (withdrawn the day it was written — WeKnora's skills are human-written and assume a shell, and they pay for it), a Wasm component runtime (better on every axis including the determinism re-parse needs, still not built because the reason is priority), a service identity per app, an app as a saved conversation. Reopened by a named customer who needs a button inside the product, by the type layer settling, or after 0034 | | 0047 | [A rule may conclude a relation](0047-a-rule-may-conclude-a-relation.md) | Proposed 2026-09-20 · nothing built · A rule reads one entity and concludes about that same entity, so a threshold over a chain — a holding above 50% in a company that itself holds above 50% in another — cannot be written at all, and the query-time path walk that answers it produces no interval, no premises and nothing a queue can see. The conclusion becomes a **relation** between the subject and one entity reached across one declared relation, valid on the intersection of every premise interval including the join edge's. The concluded edge rejoins the pool `derive()` reads and the axiom pass runs once per round, coupling the two reasoners for the first time: 0021's cycle objection is answered with the **finiteness** argument [0030](0030-a-rule-may-read-what-a-rule-concluded.md) already put in place of acyclicity, rather than with a fixed ordering that would let a legitimate rule silently never fire. Reading a value across a hop is [0032](0032-a-rule-computes-what-it-concludes.md)'s decision, reused rather than re-decided. Negation, aggregation, a second hop and user-defined recursion stay out; the three caps in play are set by measurement in the PR that changes them | +| 0049 | [Expression declarations are checked when a rule is written](0049-expression-declarations-are-checked-when-a-rule-is-written.md) | Proposed · declaration policy pending; opt-in web draft only | | | Record | Domain | Status | |---|---|---|---| @@ -123,6 +124,7 @@ The test for writing one: if someone (including us) looks at a piece of code in | 0045 | [A time mention is resolved against its document](0045-a-time-mention-is-resolved-against-its-document.md) | time | current | | 0046 | [The app surface is MCP](0046-the-app-surface-is-mcp.md) | chat-and-mcp | current | | 0047 | [A rule may conclude a relation](0047-a-rule-may-conclude-a-relation.md) | rules | current | +| 0049 | [Expression declarations are checked when a rule is written](0049-expression-declarations-are-checked-when-a-rule-is-written.md) | rules | proposed | The status word is whether a later record has overtaken this one; what is built is in the record's own status line. Domains are the files of [../design/](../design/README.md), where every record is dated and the status words are defined. diff --git a/scripts/prototypes/expressions/README.md b/scripts/prototypes/expressions/README.md deleted file mode 100644 index 36a8847e1..000000000 --- a/scripts/prototypes/expressions/README.md +++ /dev/null @@ -1,87 +0,0 @@ -# Proposed declaration policy for the expression picker - -Status: **proposed; no production validation or editor changes**. Refs #488. -ADR 0032 requires unit/datatype checks, but does not define what a missing unit -means or authorize implicit conversion. The separate expression-operand API fix -restores an already described capability; this proposal deliberately does not -silently tighten that API's accepted computed definitions. - -## Decision requested - -Approve a conservative write-time declaration subset: numeric `number` attributes, -exact known units, no conversion, and missing/ambiguous units as **unknown** rather -than dimensionless. Decide whether the exact string `1` is the explicit unitless -representation. Prefer enabling same-unit addition/subtraction and numeric factor -scaling first; ratios need the explicit-unitless decision for their target. - -The executable policy uses USD/EUR/m/kg/s as experimental known declarations, not -an exhaustive units language. It treats $, ¥, %, basis points, Celsius and arbitrary -compound strings as unknown. The accepted allowlist must be agreed alongside `1`; -it must not infer aliases from labels, backfill empty units, or claim that matching -declarations normalize historic observations. - -| Operation | Proposed accepted inputs | Result | -|---|---|---| -| add/subtract | equal known units, or both explicitly unitless | same unit | -| multiply | at least one unitless | other operand's unit | -| divide | unitless denominator, or identical known units | numerator, or unitless | -| constant | finite decimal number | unitless factor | - -A bare `revenue(USD)-1` is rejected. A scalar legacy threshold remains governed by -its existing API contract; this policy applies to editing expressions, not a rewrite -of all stored conditions. Changing declarations later can invalidate assumptions: -this is a write-time check, not a new ontology lifecycle/revision system. - -## Transaction boundary proposed for the API - -Resolve references in the current KB, sort their UUIDs, read/lock the relevant -attribute declarations with `FOR SHARE`, validate, then write the rule in the same -short transaction. `FOR KEY SHARE` is insufficient for concurrent datatype/unit -updates. Metadata-only PATCH and existing enabled toggles do not rewrite/revalidate -legacy definitions. Deletion/foreign-base/permission checks remain server-owned. - -The PostgreSQL experiment observes a real blocked declaration UPDATE via -`pg_blocking_pids`; the writer continues to read USD until commit, after which a -subsequent validator sees EUR. This establishes the proposed lock primitive, **not** -that production rule routes already perform it. Keep this separate from A0. - -## Editor experiment and evidence - -`scripts/prototypes/expressions/` holds a standalone picker and policy module: - -```sh -node --test scripts/prototypes/expressions/policy.test.mjs -python -m http.server 5190 --bind 127.0.0.1 --directory scripts/prototypes/expressions -# Open http://127.0.0.1:5190; all saves are local model state, never API requests. -``` - -For the optional DB lock experiment, install psycopg[binary]==3.2.10 in an isolated -venv and run `python -m unittest -v test_declaration_lock` from that directory with -UTOPIA_DATABASE_URL pointing to an isolated PG16 test database. It creates/drops a -random `unit_model_*` schema and releases the writer even on assertion failure. - -Linux: **10 Node tests and 1 PostgreSQL lock experiment passed**. Tests cover the -operation table, unknown versus unitless, nonnumeric/missing attributes, non-finite -and unfinished constants, UUID-independent label changes, grouped-condition -preservation, metadata-only patches, mixed/unknown legacy shapes, constant-only -rejection and depth. Root depth is zero: four edges/five nodes is accepted by the -API and parser; the independent Expr::depth method uses leaf depth one. - -A headless Chromium interaction check passed local save/reopen, invalid constant, -failed-save draft retention/retry, and unknown-definition metadata-only save. This -is **not real-backend E2E**, is not the final localized/accessibility-reviewed UI, -and does not replace B1. The toy attribute identifiers illustrate tree identity; -production UUID and same-KB validation remain the API's responsibility. - -## Implementation after approval - -Add server declaration validation in the short write transaction, then integrate -structured drafts into RulesPanel using the existing expression display and protected -metadata editor. Keep grouping, explicit scalar/expression modes, failed-save drafts, -KB switching, UUID selection and exact tree order. Reuse known/unknown shape checks; -never strip unknown keys to make a definition editable. Preview and save must use -the same validated AST. Add actual browser/API create-read-edit-read, concurrent -declaration updates and inference/premise/interval regressions before enabling it. - -Rollback of UI retains B1. It does not delete existing rules. No new AST shapes, -relation paths, aggregate operators, formula runtime or unit conversion are proposed. diff --git a/scripts/prototypes/expressions/index.html b/scripts/prototypes/expressions/index.html deleted file mode 100644 index bf3ef1609..000000000 --- a/scripts/prototypes/expressions/index.html +++ /dev/null @@ -1,31 +0,0 @@ -Expression policy experiment - -

Expression policy experiment

Proposed declaration rules — isolated prototype, no production API writes.

- - - -


-
-

Last local payload

No writes
- diff --git a/scripts/prototypes/expressions/policy.mjs b/scripts/prototypes/expressions/policy.mjs deleted file mode 100644 index fe3d999cb..000000000 --- a/scripts/prototypes/expressions/policy.mjs +++ /dev/null @@ -1,59 +0,0 @@ -// Proposed declaration policy, deliberately outside the production editor/API. -export const MAX_DEPTH = 4; // root=0, checked against authenticated API fixtures -const known = new Set(['USD', 'EUR', 'm', 'kg', 's']); -const numeric = /^[-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?$/; -export function declaration(attribute, allowUnitless = false) { - if (!attribute || attribute.datatype !== 'number') throw Error('numeric_attribute_required'); - if (attribute.unit === '1' && allowUnitless) return '1'; - if (!known.has(attribute.unit)) throw Error('unknown_unit'); - return attribute.unit; -} -export function build(draft, attributes, options = {}, depth = 0) { - if (depth > MAX_DEPTH) throw Error('expression_too_deep'); - if (!draft || draft.kind === 'empty') throw Error('incomplete_expression'); - if (draft.kind === 'attr') { - const attr = attributes.find(a => a.id === draft.id); - return { ast: { attr: draft.id }, unit: declaration(attr, options.allowUnitless), reads: [draft.id] }; - } - if (draft.kind === 'const') { - const text = draft.raw.trim(); - if (!numeric.test(text) || !Number.isFinite(Number(text))) throw Error('finite_number_required'); - return { ast: { const: Number(text) }, unit: '1', reads: [] }; - } - if (draft.kind !== 'arith' || !['add','sub','mul','div'].includes(draft.op)) throw Error('unsupported_expression'); - const l=build(draft.l,attributes,options,depth+1), r=build(draft.r,attributes,options,depth+1); - let unit; - if (['add','sub'].includes(draft.op) && l.unit === r.unit) unit=l.unit; - if (draft.op === 'mul' && (l.unit === '1' || r.unit === '1')) unit=l.unit === '1' ? r.unit : l.unit; - if (draft.op === 'div' && (r.unit === '1' || r.unit === l.unit)) unit=r.unit === '1' ? l.unit : '1'; - if (!unit) throw Error('incompatible_units'); - return { ast: {op:draft.op,l:l.ast,r:r.ast}, unit, reads:[...new Set([...l.reads,...r.reads])] }; -} -export function conclude(draft, target, attributes, options = {}) { - const result=build(draft,attributes,options); - if (!result.reads.length) throw Error('constant_expression'); - if (declaration(attributes.find(a => a.id===target),options.allowUnitless)!==result.unit) throw Error('target_unit_mismatch'); - return result.ast; -} -export function reopen(ast, depth=0) { - if (!ast || typeof ast !== 'object' || Array.isArray(ast) || depth>MAX_DEPTH) return null; - const keys=Object.keys(ast).sort().join(','); - if (keys==='attr' && typeof ast.attr==='string') return {kind:'attr',id:ast.attr}; - // Legacy string constants stay metadata-only; no silent conversion on reopen. - if (keys==='const' && typeof ast.const==='number' && Number.isFinite(ast.const)) return {kind:'const',raw:String(ast.const)}; - if (keys!=='l,op,r' || !['add','sub','mul','div'].includes(ast.op)) return null; - const l=reopen(ast.l,depth+1),r=reopen(ast.r,depth+1); - return l && r ? {kind:'arith',op:ast.op,l,r} : null; -} -export function preview(ast, attributes) { - if ('attr' in ast) { const a=attributes.find(a=>a.id===ast.attr); return a ? `${a.label} [${a.key}]` : ast.attr; } - if ('const' in ast) return String(ast.const); - return `(${preview(ast.l,attributes)} ${{add:'+',sub:'−',mul:'×',div:'÷'}[ast.op]} ${preview(ast.r,attributes)})`; -} -export function patch(original, metadata, definition) { - const result={name:metadata.name,description:metadata.description}; - // The editor only supplies definition after explicit opt-in. Group identities - // and UUIDs are copied as a whole, never rebuilt from labels or flattened. - if (definition && JSON.stringify(definition)!==JSON.stringify(original)) Object.assign(result,structuredClone(definition)); - return result; -} diff --git a/scripts/prototypes/expressions/policy.test.mjs b/scripts/prototypes/expressions/policy.test.mjs deleted file mode 100644 index 00a6f8d1a..000000000 --- a/scripts/prototypes/expressions/policy.test.mjs +++ /dev/null @@ -1,50 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import {build,conclude,reopen,preview,patch,declaration} from './policy.mjs'; -const a=[{id:'r',key:'revenue',label:'Amount',datatype:'number',unit:'USD'},{id:'c',key:'cost',label:'Amount',datatype:'number',unit:'USD'},{id:'e',key:'euro',label:'Amount',datatype:'number',unit:'EUR'},{id:'ratio',key:'ratio',label:'Ratio',datatype:'number',unit:'1'}]; -const attr=id=>({kind:'attr',id}), num=raw=>({kind:'const',raw}), op=(op,l,r)=>({kind:'arith',op,l,r}); -const margin=op('sub',attr('r'),attr('c')); -test('same-unit subtraction and dimensionless scaling preserve the tree',()=>{ - const tree=op('mul',margin,num('1.1')); const ast=conclude(tree,'r',a); - assert.deepEqual(ast,{op:'mul',l:{op:'sub',l:{attr:'r'},r:{attr:'c'}},r:{const:1.1}}); - assert.deepEqual(conclude(reopen(ast),'r',a),ast); -}); -test('unknown, missing, offset, ambiguous and compound units are not unitless',()=>{ - for(const unit of [null,undefined,'','1','$','¥','%','bp','°C','USD/year']) assert.throws(()=>declaration({...a[0],unit})); - assert.equal(declaration(a[3],true),'1'); -}); -test('mismatched add/sub, dimensioned multiplication and bare subtract are rejected',()=>{ - for(const tree of [op('add',attr('r'),attr('e')),op('sub',attr('r'),num('1')),op('mul',attr('r'),attr('c')),op('div',attr('r'),attr('e'))]) assert.throws(()=>build(tree,a),/incompatible_units/); -}); -test('ratio requires separately approved explicit-unitless target',()=>{ - const tree=op('div',margin,attr('r')); - assert.throws(()=>conclude(tree,'ratio',a),/unknown_unit/); - assert.deepEqual(conclude(tree,'ratio',a,{allowUnitless:true}),{op:'div',l:{op:'sub',l:{attr:'r'},r:{attr:'c'}},r:{attr:'r'}}); - assert.throws(()=>conclude(tree,'r',a,{allowUnitless:true}),/target_unit_mismatch/); -}); -test('draft fragments, non-finite and non-decimal strings never become zero',()=>{ - for(const raw of ['', ' ','-','1e','NaN','Infinity','0x10','1e999']) assert.throws(()=>build(num(raw),a)); - for(const raw of ['0','-2','.5','1e2']) assert.equal(build(num(raw),a).ast.const,Number(raw)); -}); -test('missing and nonnumeric attributes reject; labels never identify nodes',()=>{ - assert.throws(()=>build(attr('missing'),a)); - assert.throws(()=>build(attr('r'),[{...a[0],datatype:'text'}])); - assert.match(preview(build(margin,a).ast,a),/revenue.*cost/); - const renamed=a.map(x=>({...x,label:'Renamed'})); - assert.deepEqual(build(margin,a).ast,build(margin,renamed).ast); -}); -test('depth boundary is four edges and five nodes, next edge rejects',()=>{ - let tree=attr('r'); for(let i=0;i<4;i++)tree=op('mul',tree,num('1')); - assert.ok(build(tree,a)); assert.throws(()=>build(op('mul',tree,num('1')),a),/expression_too_deep/); -}); -test('unknown and mixed legacy shapes remain protected',()=>{ - for(const ast of [{attr:'r',extra:true},{const:'2'},{op:'pow',l:{attr:'r'},r:{const:2}},{attr:'r',const:2}]) assert.equal(reopen(ast),null); -}); -test('metadata-only patch does not rewrite a legacy definition or condition groups',()=>{ - const original={conclude_expr:{future:'unknown'},conditions:[{group:2},{group:7}]}; - assert.deepEqual(patch(original,{name:'New',description:'D'}),{name:'New',description:'D'}); - assert.deepEqual(patch(original,{name:'New',description:'D'},original),{name:'New',description:'D'}); - const changed={...original,conclude_expr:{attr:'r'}}; - assert.deepEqual(patch(original,{name:'New',description:'D'},changed).conditions,[{group:2},{group:7}]); -}); -test('constant-only computed rules stay rejected',()=>assert.throws(()=>conclude(num('4'),'r',a),/constant_expression/)); diff --git a/scripts/prototypes/expressions/test_declaration_lock.py b/scripts/prototypes/expressions/test_declaration_lock.py deleted file mode 100644 index 22af144d4..000000000 --- a/scripts/prototypes/expressions/test_declaration_lock.py +++ /dev/null @@ -1,49 +0,0 @@ -"""Proposed write-time lock boundary on PostgreSQL; not production API validation.""" -import concurrent.futures -import os -import time -import unittest -import uuid -import psycopg -from psycopg import sql - -class DeclarationLock(unittest.TestCase): - def test_share_lock_keeps_unit_stable_through_definition_commit(self): - schema='unit_model_'+uuid.uuid4().hex - dsn=os.environ['UTOPIA_DATABASE_URL'] - with psycopg.connect(dsn,autocommit=True) as admin: - admin.execute(sql.SQL('CREATE SCHEMA {}').format(sql.Identifier(schema))) - admin.execute(sql.SQL('CREATE TABLE {}.attribute(id int PRIMARY KEY, unit text); INSERT INTO {}.attribute VALUES(1,\'USD\')').format(sql.Identifier(schema),sql.Identifier(schema))) - def connection(): - c=psycopg.connect(dsn) - c.execute(sql.SQL('SET search_path TO {}').format(sql.Identifier(schema))) - return c - try: - with connection() as writer: - pid=writer.execute('SELECT pg_backend_pid()').fetchone()[0] - self.assertEqual(writer.execute('SELECT unit FROM attribute WHERE id=1 FOR SHARE').fetchone()[0],'USD') - def update(): - with connection() as updater: - updater.execute("UPDATE attribute SET unit='EUR' WHERE id=1") - with concurrent.futures.ThreadPoolExecutor(1) as ex: - future=ex.submit(update) - try: - deadline=time.monotonic()+5 - while time.monotonic() `${n} attributes loaded from this knowledge base`, + }, app: { name: "Utopia", // 化用《乌托邦》全书最后一句(Burnet 1684 译本): diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 0bf28c2fa..4adde926f 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -10,6 +10,36 @@ import type { Strings } from "./en"; export const zh: Strings = { + expressionDraft: { + title: "表达式草稿探索", + unsaved: "仅为未保存的草稿,不会写入知识库,也不检查单位兼容性。", + undeclared: "未声明", + attribute: "属性", + constant: "数字", + add: "加 (+)", + sub: "减 (−)", + mul: "乘 (×)", + div: "除 (÷)", + expression: "表达式", + left: "左操作数", + right: "右操作数", + kind: "节点类型", + depthLimit: "已达嵌套深度上限,请选择属性或数字。", + choose: "搜索并选择…", + missing: "属性已不可用", + loading: "正在读取属性和规则…", + loadError: "无法读取此知识库,请检查访问权限后重试。", + retry: "重试", + empty: "此知识库尚无属性。", + conclusion: "结论", + condition: "条件", + existing: "探索已有表达式", + unsupported: "此表达式结构尚不支持,未对其进行转换。名称和说明仍可在原规则编辑器中修改。", + preview: "草稿预览(未保存)", + incomplete: "请为每个操作数选择可用属性或填写有限数字以预览。", + reset: "新建草稿", + count: (n: number) => `已读取此知识库的 ${n} 个属性`, + }, app: { name: "Utopia", /* 标语与出处都与 Utopia / Persona / Charter 同类:品牌的一部分,两种语言同值 */ diff --git a/web/src/pages/ExpressionDraftEditor.tsx b/web/src/pages/ExpressionDraftEditor.tsx new file mode 100644 index 000000000..7fb59dfc3 --- /dev/null +++ b/web/src/pages/ExpressionDraftEditor.tsx @@ -0,0 +1,52 @@ +import { useMemo } from "react"; +import type { RelationTypeView } from "../api"; +import { S } from "../i18n"; +import { Dropdown, Field, Input, SearchSelect, type SearchSelectOption } from "../ui"; +import type { ExpressionDraft } from "./expressionDraft"; + +/** Controlled tree editor: replacing a node never rewrites its siblings or grouping. */ +export function ExpressionDraftEditor({ value, onChange, attributes }: { + value: ExpressionDraft; + onChange: (value: ExpressionDraft) => void; + attributes: RelationTypeView[]; +}) { + const options = useMemo(() => attributes.map((a) => ({ + value: a.id, label: a.label, + hint: `${a.key} · ${a.datatype ?? S.expressionDraft.undeclared} · ${a.unit ?? S.expressionDraft.undeclared} · ${a.id}`, + })), [attributes]); + return ; +} + +function DraftNode({ value, onChange, options, depth, path }: { + value: ExpressionDraft; onChange: (value: ExpressionDraft) => void; + options: SearchSelectOption[]; depth: number; path: string; +}) { + const selected = "attr" in value ? options.find((a) => a.value === value.attr) : undefined; + const kind = "attr" in value ? "attr" : "const" in value ? "const" : value.op; + const kinds = [ + { value: "attr", label: S.expressionDraft.attribute }, + { value: "const", label: S.expressionDraft.constant }, + ...(depth < 4 ? ["add", "sub", "mul", "div"].map((op) => ({ value: op, label: S.expressionDraft[op as "add" | "sub" | "mul" | "div"] })) : []), + ]; + const changeKind = (next: string) => { + if (next === kind) return; + if (next === "attr") onChange({ attr: "" }); + else if (next === "const") onChange({ const: "" }); + else onChange({ op: next as "add" | "sub" | "mul" | "div", l: "op" in value ? value.l : value, r: "op" in value ? value.r : { attr: "" } }); + }; + return
+ {depth === 0 ? S.expressionDraft.expression : path.endsWith("l") ? S.expressionDraft.left : S.expressionDraft.right} + + {depth === 4 &&

{S.expressionDraft.depthLimit}

} + {"attr" in value ? + onChange({ attr })} placeholder={S.expressionDraft.choose} className="w-full min-w-0" /> + {selected &&

{selected.label} · {selected.hint}

} + {value.attr && !selected &&

{S.expressionDraft.missing}: {value.attr}

} +
: "const" in value ? + onChange({ const: e.target.value })} /> + :
+ onChange({ ...value, l })} options={options} depth={depth + 1} path={`${path}.l`} /> + onChange({ ...value, r })} options={options} depth={depth + 1} path={`${path}.r`} /> +
} +
; +} diff --git a/web/src/pages/ExpressionDraftLab.tsx b/web/src/pages/ExpressionDraftLab.tsx new file mode 100644 index 000000000..425863dad --- /dev/null +++ b/web/src/pages/ExpressionDraftLab.tsx @@ -0,0 +1,58 @@ +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { api } from "../api"; +import { S } from "../i18n"; +import { useKbId } from "../kb"; +import { Button, Field, PageHeader, SearchSelect } from "../ui"; +import { ExpressionDraftEditor } from "./ExpressionDraftEditor"; +import { draftFromExpression, previewExpression, type ExpressionDraft } from "./expressionDraft"; +import { expressionText, readExpression } from "./ruleExpressions"; + +/** Explicit unlisted route; a KB change remounts all local draft state. */ +export function ExpressionDraftLab() { + const kbId = useKbId(); + return ; +} + +function Lab({ kbId }: { kbId: string }) { + const ontology = useQuery({ queryKey: ["ontology", kbId], queryFn: () => api.ontology(kbId) }); + const rules = useQuery({ queryKey: ["rules", kbId], queryFn: () => api.rules(kbId) }); + const [draft, setDraft] = useState({ attr: "" }); + const [source, setSource] = useState(""); + const [unsupported, setUnsupported] = useState(false); + if (ontology.isPending || rules.isPending) return

{S.expressionDraft.loading}

; + if (ontology.isError || rules.isError) return
+

{S.expressionDraft.loadError}

+ +
; + const attributes = ontology.data.relation_types.filter((a) => a.kind === "attribute"); + // The ontology endpoint returns the full relation list; there is no client cap. + const candidates = rules.data.rules.flatMap((rule) => [ + ...(rule.conclusion === "computed" ? [{ value: `${rule.id}/conclusion`, label: rule.name, hint: S.expressionDraft.conclusion, raw: rule.conclude_expr }] : []), + ...rule.conditions.flatMap((c, i) => c.operand && typeof c.operand === "object" && !Array.isArray(c.operand) + ? [{ value: `${rule.id}/${i}`, label: rule.name, hint: `${S.expressionDraft.condition} ${i + 1}`, raw: c.operand }] : []), + ]); + const preview = previewExpression(draft, new Set(attributes.map((a) => a.id))); + return
+ +

{S.expressionDraft.count(attributes.length)}

+ {!attributes.length &&

{S.expressionDraft.empty}

} + + { + const raw = candidates.find((c) => c.value === id)?.raw; + const expr = readExpression(raw); + setSource(id); setUnsupported(!expr); + if (expr) setDraft(draftFromExpression(expr)); + }} /> + + {unsupported ?

{S.expressionDraft.unsupported}

: <> + +
+

{S.expressionDraft.preview}

+

{preview ? expressionText(preview, attributes, S.ontology.ruleUnknownExpression) : S.expressionDraft.incomplete}

+ {preview &&
{JSON.stringify(preview, null, 2)}
} +
+ } + +
; +} diff --git a/web/src/pages/expressionDraft.test.ts b/web/src/pages/expressionDraft.test.ts new file mode 100644 index 000000000..129d99b63 --- /dev/null +++ b/web/src/pages/expressionDraft.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { draftFromExpression, previewExpression, type ExpressionDraft } from "./expressionDraft"; +import { readExpression } from "./ruleExpressions"; + +const a = "00000000-0000-0000-0000-000000000001"; +const b = "00000000-0000-0000-0000-000000000002"; +const c = "00000000-0000-0000-0000-000000000003"; +const ids = new Set([a, b, c]); +describe("expression draft fidelity", () => { + it("preserves right-nested subtraction and division on reopen and inner edits", () => { + for (const op of ["sub", "div"] as const) { + const expr = { op, l: { attr: a }, r: { op, l: { attr: b }, r: { attr: c } } }; + const draft = draftFromExpression(expr); + expect(previewExpression(draft, ids)).toEqual(expr); + if (!("op" in draft) || !("op" in draft.r)) throw new Error("lost tree"); + const changed = { ...draft, r: { ...draft.r, r: { const: "2.5" } } }; + expect(previewExpression(changed, ids)).toEqual({ ...expr, r: { ...expr.r, r: { const: "2.5" } } }); + expect(draft).toEqual(expr); + } + }); + it("never substitutes zero or a first attribute for incomplete and missing inputs", () => { + for (const text of ["", "-", "+", "1e", "Infinity", "NaN", "1.2.3"]) { + expect(previewExpression({ const: text }, ids)).toBeNull(); + } + expect(previewExpression({ attr: "" }, ids)).toBeNull(); + expect(previewExpression({ attr: a }, new Set())).toBeNull(); + expect(previewExpression({ const: "0" }, ids)).toEqual({ const: "0" }); + }); + it("uses the existing structural parser depth and unknown-shape boundary", () => { + let draft: ExpressionDraft = { attr: a }; + for (let i = 0; i < 4; i++) draft = { op: "add", l: draft, r: { const: "1" } }; + expect(previewExpression(draft, ids)).not.toBeNull(); + expect(previewExpression({ op: "add", l: draft, r: { attr: a } }, ids)).toBeNull(); + expect(readExpression({ attr: a, future: true })).toBeNull(); + }); +}); diff --git a/web/src/pages/expressionDraft.ts b/web/src/pages/expressionDraft.ts new file mode 100644 index 000000000..f6a77c4df --- /dev/null +++ b/web/src/pages/expressionDraft.ts @@ -0,0 +1,22 @@ +import { readExpression, type RuleExpression } from "./ruleExpressions"; + +/** Unfinished text is draft state; it must never become the numeric zero. */ +export type ExpressionDraft = + | { attr: string } + | { const: string } + | { op: "add" | "sub" | "mul" | "div"; l: ExpressionDraft; r: ExpressionDraft }; + +export function draftFromExpression(expr: RuleExpression): ExpressionDraft { + if ("const" in expr) return { const: String(expr.const) }; + if ("attr" in expr) return { attr: expr.attr }; + return { op: expr.op, l: draftFromExpression(expr.l), r: draftFromExpression(expr.r) }; +} + +/** Structural preview only. Declaration policy and server write validation are separate. */ +export function previewExpression(draft: ExpressionDraft, attributeIds: ReadonlySet): RuleExpression | null { + const parsed = readExpression(draft); + if (!parsed) return null; + const referencesExist = (node: RuleExpression): boolean => + "attr" in node ? attributeIds.has(node.attr) : "const" in node || (referencesExist(node.l) && referencesExist(node.r)); + return referencesExist(parsed) ? parsed : null; +} diff --git a/web/src/router.tsx b/web/src/router.tsx index 386093222..18e2bac3e 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -1,3 +1,4 @@ +import { ExpressionDraftLab } from "./pages/ExpressionDraftLab"; import { createRootRoute, createRoute, @@ -133,6 +134,13 @@ const libraryRoute = createRoute({ component: Library, }); +// Unlisted opt-in exploration: authenticated reads, local drafts, no persistence. +const expressionDraftRoute = createRoute({ + getParentRoute: () => kbRoute, + path: "expression-draft", + component: ExpressionDraftLab, +}); + const ontologyRoute = createRoute({ getParentRoute: () => kbRoute, path: "ontology", @@ -309,6 +317,7 @@ const routeTree = rootRoute.addChildren([ libraryRoute, reviewRoute, ontologyRoute, + expressionDraftRoute, mappingsRoute, kbSettingsRoute, ]),