diff --git a/src/core/graph/nodes/data/Compare.ts b/src/core/graph/nodes/data/Compare.ts index c84c931a..4aefa7e0 100644 --- a/src/core/graph/nodes/data/Compare.ts +++ b/src/core/graph/nodes/data/Compare.ts @@ -1,15 +1,16 @@ import type { NodeDef } from '../../NodeRegistry'; -import { asNumber } from '../../values'; +import { asNumber, nearlyEqual } from '../../values'; import { bool, num } from '../shared'; import { textOf } from '../../../model/Properties'; +// All six share one notion of equal, so `==` and `>` are never true at once. const OPERATORS: Record boolean> = { - '==': (a, b) => a === b, - '!=': (a, b) => a !== b, - '<': (a, b) => a < b, - '<=': (a, b) => a <= b, - '>': (a, b) => a > b, - '>=': (a, b) => a >= b, + '==': (a, b) => nearlyEqual(a, b), + '!=': (a, b) => !nearlyEqual(a, b), + '<': (a, b) => a < b && !nearlyEqual(a, b), + '<=': (a, b) => a < b || nearlyEqual(a, b), + '>': (a, b) => a > b && !nearlyEqual(a, b), + '>=': (a, b) => a > b || nearlyEqual(a, b), }; export const Compare: NodeDef = { diff --git a/src/core/graph/nodes/data/Equals.ts b/src/core/graph/nodes/data/Equals.ts index 28c8bf2b..a69acf6f 100644 --- a/src/core/graph/nodes/data/Equals.ts +++ b/src/core/graph/nodes/data/Equals.ts @@ -1,12 +1,13 @@ import type { NodeDef } from '../../NodeRegistry'; -import { asEntity, asString } from '../../values'; +import { asEntity, asString, nearlyEqual } from '../../values'; import { any, bool } from '../shared'; export const Equals: NodeDef = { type: 'Equals', label: 'Equals', category: 'data', - description: 'Whether two values are the same. Objects compare by identity, everything else as text.', + description: + 'Whether two values are the same. Objects compare by identity, two numbers up to float noise, everything else as text.', inputs: [any('a'), any('b')], outputs: [bool('result')], params: [], @@ -14,6 +15,9 @@ export const Equals: NodeDef = { const a = asEntity(inputs.a); const b = asEntity(inputs.b); if (a || b) return { outputs: { result: a === b } }; + if (typeof inputs.a === 'number' && typeof inputs.b === 'number') { + return { outputs: { result: nearlyEqual(inputs.a, inputs.b) } }; + } return { outputs: { result: asString(inputs.a) === asString(inputs.b) } }; }, }; diff --git a/src/core/graph/values.ts b/src/core/graph/values.ts index ae95516b..0875da81 100644 --- a/src/core/graph/values.ts +++ b/src/core/graph/values.ts @@ -25,6 +25,16 @@ export function isEntity(value: Value | undefined): value is EntityHandle { return typeof value === 'object' && value !== null && ENTITY_HANDLE in value; } +/** + * Whether two numbers are the same up to float noise (#224): a relative 1e-9, never less than 1e-9 + * absolute. `time - late` lands 1e-14 off a whole second, and after hours of summed `dt` the error + * still stays far below this, while 6 and 6.001 remain apart. + */ +export function nearlyEqual(a: number, b: number): boolean { + if (a === b) return true; + return Math.abs(a - b) <= 1e-9 * Math.max(1, Math.abs(a), Math.abs(b)); +} + /** A list of anything a data edge carries – a handle among the items is what `isList` cannot say. */ function isValueList(value: Value | undefined): value is ValueList { return Array.isArray(value); diff --git a/tests/core/node-catalog.test.ts b/tests/core/node-catalog.test.ts index 57e41f72..736f564a 100644 --- a/tests/core/node-catalog.test.ts +++ b/tests/core/node-catalog.test.ts @@ -685,11 +685,72 @@ const CASES: Record = { { name: 'equal text', inputs: { a: 'a', b: 'a' }, outputs: { result: true } }, { name: 'a number equals its text form', inputs: { a: 3, b: '3' }, outputs: { result: true } }, { name: 'objects compare by identity, not by name', inputs: eqNamesakes(), outputs: { result: false } }, + { + name: 'two numbers ignore float noise', + inputs: { a: 5.999999999999972, b: 6 }, + outputs: { result: true }, + }, + { name: 'two numbers still tell 6.001 from 6', inputs: { a: 6.001, b: 6 }, outputs: { result: false } }, ], Compare: [ { name: 'less than', inputs: { a: 1, b: 2 }, params: { op: '<' }, outputs: { result: true } }, { name: 'greater or equal', inputs: { a: 2, b: 2 }, params: { op: '>=' }, outputs: { result: true } }, { name: 'not equal', inputs: { a: 2, b: 2 }, params: { op: '!=' }, outputs: { result: false } }, + // time - late after 6 s of summed dt (#224): the noise counts as equal under every operator. + { + name: '== ignores float noise', + inputs: { a: 5.999999999999972, b: 6 }, + params: { op: '==' }, + outputs: { result: true }, + }, + { + name: '!= ignores float noise', + inputs: { a: 5.999999999999972, b: 6 }, + params: { op: '!=' }, + outputs: { result: false }, + }, + { + name: '< ignores float noise', + inputs: { a: 5.999999999999972, b: 6 }, + params: { op: '<' }, + outputs: { result: false }, + }, + { + name: '>= ignores float noise', + inputs: { a: 5.999999999999972, b: 6 }, + params: { op: '>=' }, + outputs: { result: true }, + }, + { + name: '> ignores float noise', + inputs: { a: 6.000000000000028, b: 6 }, + params: { op: '>' }, + outputs: { result: false }, + }, + { + name: '<= ignores float noise', + inputs: { a: 6.000000000000028, b: 6 }, + params: { op: '<=' }, + outputs: { result: true }, + }, + { + name: 'the tolerance grows with the values', + inputs: { a: 1e6, b: 1e6 + 1e-4 }, + params: { op: '==' }, + outputs: { result: true }, + }, + { + name: 'a real difference stays one', + inputs: { a: 6.001, b: 6 }, + params: { op: '==' }, + outputs: { result: false }, + }, + { + name: 'a tiny value still differs from 0', + inputs: { a: 1e-6, b: 0 }, + params: { op: '>' }, + outputs: { result: true }, + }, { name: 'no operator chosen falls back to the default "<"', inputs: { a: 1, b: 2 },