From a457153173c4e5089ddc11c42a551c6f9dfe0cfb Mon Sep 17 00:00:00 2001 From: neu-rah Date: Thu, 8 Oct 2026 23:00:20 +0000 Subject: [PATCH 1/2] python/onemachine: role_facts refuses a scale with a numerator or denominator <= 0 presented = raw * num / den. A scale that is zero or negative in either part has no meaning for an analog or a scaled value, and for a scaled one it reverses its bounds. The check was only made for a scaled role; it is now made for both kinds, right after the scale and unit lines, and names the role. A denominator of 0 is still refused by the description itself. facts_check.py has a case for each: numerator 0, negative numerator, negative denominator, on an analog and on a scaled role. Co-Authored-By: Claude Sonnet 5.5 --- python/onemachine/machine.py | 4 ++-- test/role/facts_check.py | 9 ++++++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/python/onemachine/machine.py b/python/onemachine/machine.py index 75eade8..4aa0d1e 100644 --- a/python/onemachine/machine.py +++ b/python/onemachine/machine.py @@ -287,7 +287,7 @@ def unpack_text(self, array): def role_facts(m, name): """The Facts of role `name` of Machine m, or KindError. Checks the description against the kind: only the lines the kind prints, the fields it reports and - commands present with the types it needs, the bound param, distinct values, unique labels, a positive scale for a scaled value, a flag that is unsigned.""" + commands present with the types it needs, the bound param, distinct values, unique labels, a positive scale, a flag that is unsigned.""" info = m.roles[name] if info.kind not in KINDS: raise KindError('role %s is of kind %r; python/onemachine has facts for %s' % (name, info.kind, ', '.join(sorted(KINDS)))) k, f = KINDS[info.kind], Facts(name, info.kind) @@ -331,9 +331,9 @@ def refuse(why): raise KindError('role %s (%s): %s' % (name, info.kind, why)) for line, d in (('scale', info.scales), ('unit', info.units)): if set(d) != {k.report}: refuse('the description needs one `%s` line, for the field %r (it has %s)' % (line, k.report, sorted(d) or 'none')) f.scale, f.unit = info.scales[k.report], info.units[k.report] + if f.scale[0] <= 0 or f.scale[1] <= 0: refuse('scale %d/%d is not positive' % f.scale) if info.kind == 'scaled': if k.upper not in info.params: refuse('no param %r in its description, and the %s kind says it bounds %r' % (k.upper, info.kind, k.command)) - if f.scale[0] <= 0 or f.scale[1] <= 0: refuse('scale %d/%d is not positive' % f.scale) hi = info.params[k.upper] if not (0 <= hi <= thi): refuse('bound [0, %d] does not fit the %s field %r' % (hi, f.command_type, k.command)) f.low, f.high = 0, hi diff --git a/test/role/facts_check.py b/test/role/facts_check.py index c34fe6f..97704ed 100644 --- a/test/role/facts_check.py +++ b/test/role/facts_check.py @@ -4,7 +4,7 @@ import os, sys sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '../../python')) from fractions import Fraction -from onemachine import Machine, StreamLink, KindError, LinkError, role_facts, KINDS +from onemachine import Machine, StreamLink, SchemaError, KindError, LinkError, role_facts, KINDS from onemachine.machine import _fnv, OK from onemachine.schema import Field, Layer @@ -83,6 +83,13 @@ def after(ls, line, *new): i = ls.index(line); ls[i + 1:i + 1] = list(new) refused('an analog with no `scale` line', lambda ls: drop(ls, 'scale vin raw 5 1023'), 'vin', 'scale') refused('an analog whose `unit` is for another field', lambda ls: sub(ls, 'unit vin raw V', 'unit vin other V'), 'vin', 'unit') refused('a scaled with a scale of 0/10', lambda ls: sub(ls, 'scale duty raw 1 10', 'scale duty raw 0 10'), 'duty', 'not positive') +refused('an analog with a scale whose numerator is 0 (0/1023)', lambda ls: sub(ls, 'scale vin raw 5 1023', 'scale vin raw 0 1023'), 'vin', 'scale 0/1023', 'not positive') +refused('an analog with a negative numerator (-5/1023)', lambda ls: sub(ls, 'scale vin raw 5 1023', 'scale vin raw -5 1023'), 'vin', 'scale -5/1023', 'not positive') +refused('an analog with a negative denominator (5/-1023)', lambda ls: sub(ls, 'scale vin raw 5 1023', 'scale vin raw 5 -1023'), 'vin', 'scale 5/-1023', 'not positive') +refused('a scaled with a negative numerator (-1/10)', lambda ls: sub(ls, 'scale duty raw 1 10', 'scale duty raw -1 10'), 'duty', 'scale -1/10', 'not positive') +refused('a scaled with a negative denominator (1/-10)', lambda ls: sub(ls, 'scale duty raw 1 10', 'scale duty raw 1 -10'), 'duty', 'scale 1/-10', 'not positive') +try: Machine(Fake(rehash([('scale vin raw 5 0' if l == 'scale vin raw 5 1023' else l) for l in lines]))); check('refused: a scale with denominator 0, by the description itself', False) +except SchemaError as e: check('refused: a scale with denominator 0, by the description itself -> %s' % e, 'denominator 0' in str(e)) refused('a scaled with no `max` param', lambda ls: drop(ls, 'param duty max 1000'), 'duty', "'max'") refused('a scaled whose max does not fit its field', lambda ls: sub(ls, 'param duty max 1000', 'param duty max 70000'), 'duty', '70000') check('the machine as it is is accepted by every refusal harness', all(role_facts(Machine(Fake(rehash(list(lines)))), n) for n in m.roles)) From f10a1388808d1669dd4d401c4f971b160aa277e2 Mon Sep 17 00:00:00 2001 From: neu-rah Date: Thu, 8 Oct 2026 23:01:42 +0000 Subject: [PATCH 2/2] python/onemachine: Facts.written, a number as the client wrote it; examples/mcp compares bounds through it A value is compared with a presented bound as the decimal the client wrote, not as the double's binary value: 1.1 is 11/10, but the double 1.1 is a hair above it, so a set of exactly the maximum of a scaled value whose maximum is 1.1 (or 0.4, or 0.9) would be refused as "above the maximum 1.1". Facts.written holds that conversion (to_raw already used it); examples/mcp takes its bound comparison from it. facts_check.py: for scaled bounds 1.1, 0.4, 0.9 (the double is above the bound) and 0.3 (a control, below) it checks that written(value) is the bound, that the next double above is above it, and, through examples/mcp's own Role.refuse, that duty_set of exactly the bound is accepted and of the next double refused. It fails on a copy whose written() compares the double's binary value, and, for the consumer cases alone, on a copy of the server that does. Co-Authored-By: Claude Sonnet 5.5 --- examples/mcp/server.py | 3 +-- python/onemachine/machine.py | 10 +++++++--- test/role/facts_check.py | 22 ++++++++++++++++++++++ 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/examples/mcp/server.py b/examples/mcp/server.py index 730409a..4a4c53b 100644 --- a/examples/mcp/server.py +++ b/examples/mcp/server.py @@ -27,7 +27,6 @@ the device's state after that command. Nothing is held or resent. Needs mcp and pyserial (requirements.txt) and python/onemachine from this checkout (ONEMACHINE_PY= to use another).""" import argparse, json, os, re, sys, threading, time -from decimal import Decimal from fractions import Fraction HERE = os.path.dirname(os.path.abspath(__file__)) @@ -125,7 +124,7 @@ def refuse(self, args): if v > f.high: return None, '%s: %d is above the maximum %d' % (p, v, f.high) return v, None hi = self.top() # scaled: the bounds are on the presented value, as in the schema - x = Fraction(Decimal(repr(float(v)))) # the number as the client wrote it (12.35, not its binary neighbour) + x = f.written(v) # the number as the client wrote it (1.1, not its binary neighbour) if x < 0: return None, '%s: %r is below the minimum 0' % (p, v) if x > hi: return None, '%s: %r is above the maximum %s' % (p, v, number(hi)) return f.to_raw(v), None diff --git a/python/onemachine/machine.py b/python/onemachine/machine.py index 4aa0d1e..7352cd3 100644 --- a/python/onemachine/machine.py +++ b/python/onemachine/machine.py @@ -262,10 +262,14 @@ def __repr__(self): return 'Facts(%s %s)' % (self.name, self.kind) def presented(self, raw): """raw -> the presented value, exactly (a Fraction): raw * num / den""" return Fraction(int(raw) * self.scale[0], self.scale[1]) + def written(self, value): + """a number as the client wrote it, exactly (a Fraction): the shortest decimal that round-trips the double, so 1.1 is 11/10 and not the double's + binary value 1.100000000000000088...; a value is compared with a presented bound (presented(high)) through this, never as a float""" + return Fraction(Decimal(repr(float(value)))) def to_raw(self, value): - """presented -> raw: the exact rational of the shortest decimal that round-trips the client's number, times den / num, to the nearest integer, - halves away from zero. Integer arithmetic only: no round(), no float multiply.""" - x = Fraction(Decimal(repr(float(value)))) * self.scale[1] / self.scale[0] + """presented -> raw: the number as written, times den / num, to the nearest integer, halves away from zero. Integer arithmetic only: no round(), + no float multiply.""" + x = self.written(value) * self.scale[1] / self.scale[0] p, q = abs(x.numerator), x.denominator r = (2 * p + q) // (2 * q) return -r if x < 0 else r diff --git a/test/role/facts_check.py b/test/role/facts_check.py index 97704ed..63ed91a 100644 --- a/test/role/facts_check.py +++ b/test/role/facts_check.py @@ -94,6 +94,28 @@ def after(ls, line, *new): i = ls.index(line); ls[i + 1:i + 1] = list(new) refused('a scaled whose max does not fit its field', lambda ls: sub(ls, 'param duty max 1000', 'param duty max 70000'), 'duty', '70000') check('the machine as it is is accepted by every refusal harness', all(role_facts(Machine(Fake(rehash(list(lines)))), n) for n in m.roles)) +# ---- a value exactly at the presented bound of a scaled role is within it, and a float compared as its binary value is not: 1.1 as a double is above 11/10 +import importlib.util, math +spec = importlib.util.spec_from_file_location('mcp_server', os.path.join(os.path.dirname(os.path.abspath(__file__)), '../../examples/mcp/server.py')) +srv = importlib.util.module_from_spec(spec); spec.loader.exec_module(srv) # examples/mcp's server: the consumer that compares a value with the bound +def edited(edit): + ls = list(lines); edit(ls); return Machine(Fake(rehash(ls))) +for max_raw, bound, binary in ((11, '1.1', 'above'), (4, '0.4', 'above'), (9, '0.9', 'above'), (3, '0.3', 'below')): # duty's scale is 1/10: the bound is max/10 + mm = edited(lambda ls: sub(ls, 'param duty max 1000', 'param duty max %d' % max_raw)) + f, x = role_facts(mm, 'duty'), float(bound) + top = Fraction(max_raw, 10) + check('bound %s: presented(high) is exactly %s' % (bound, top), f.presented(f.high) == top) + check('bound %s: the double %r is %s the bound in binary (%s)' % (bound, x, binary, 'the comparison that must not be used' if binary == 'above' else 'a control: both comparisons agree'), + (Fraction(x) > top) == (binary == 'above') and (Fraction(x) < top) == (binary == 'below')) + check('bound %s: written(%r) is the bound, so the value is within it' % (bound, x), f.written(x) == top and f.written(x) <= f.presented(f.high)) + up = math.nextafter(x, 10) + check('bound %s: the next double above (%r) is above the bound' % (bound, up), f.written(up) > f.presented(f.high)) + role = {r.name: r for r in srv.model(mm)}['duty'] + raw, err = role.refuse({'value': x}) + check('examples/mcp, bound %s: duty_set %r is accepted, raw %d' % (bound, x, max_raw), err is None and raw == max_raw, (raw, err)) + raw, err = role.refuse({'value': up}) + check('examples/mcp, bound %s: duty_set %r is refused naming the maximum' % (bound, up), raw is None and 'above the maximum %s' % bound in err, (raw, err)) + # ---- the field-shape refusals: stub schemas with one field changed class S: def __init__(self, **layers): self.l = layers