Let Compare and Equals treat float noise as equal numbers
A graph that checks "time - late == 6" after On timer (#224) failed, because the difference comes out as 5.999999999999972. Both values are sums of measured frame times, and they round along different paths. The only workaround was a Round node in front of every comparison. - New helper nearlyEqual in values.ts. Two numbers count as equal when |a - b| <= 1e-9 * max(1, |a|, |b|). The tolerance grows with the size of the values, so it holds for hours of summed dt. - Compare uses the rule for all six operators. "<" and ">" only hold when the values are not nearly equal, "<=" and ">=" also hold when they are. So "==" and ">" are never true at the same time. - Equals uses the same rule when both inputs are real numbers. Objects still compare by identity, everything else as text. The node description says so. - The tolerance is fixed, there is no parameter on the node. Physics values come from float32 in Rapier and are not covered on purpose. - The log display is unchanged and still prints the raw number. Tests: - ./check.sh: 156 files, 2311 tests, CHECK EXIT 0 (before 2300). - 11 new cases in node-catalog.test.ts, including the value from the log against 6, growth with magnitude, 6.001 != 6 and 1e-6 > 0. - Counter-check exact compare: exactly the 8 noise cases fail. - Counter-check "<"/">" without the exclusion: exactly 2 cases fail. - No e2e change, the check sum stays at 1374. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6fecb2e841
commit
9d6a0aebd3
4 files changed
+85
-9
No files matched your search
@@ -1,15 +1,16 @@
|
|||||||
import type { NodeDef } from '../../NodeRegistry';
|
import type { NodeDef } from '../../NodeRegistry';
|
||||||
import { asNumber } from '../../values';
|
import { asNumber, nearlyEqual } from '../../values';
|
||||||
import { bool, num } from '../shared';
|
import { bool, num } from '../shared';
|
||||||
|
|
||||||
import { textOf } from '../../../model/Properties';
|
import { textOf } from '../../../model/Properties';
|
||||||
|
// All six share one notion of equal, so `==` and `>` are never true at once.
|
||||||
const OPERATORS: Record<string, (a: number, b: number) => boolean> = {
|
const OPERATORS: Record<string, (a: number, b: number) => boolean> = {
|
||||||
'==': (a, b) => a === b,
|
'==': (a, b) => nearlyEqual(a, b),
|
||||||
'!=': (a, b) => a !== b,
|
'!=': (a, b) => !nearlyEqual(a, b),
|
||||||
'<': (a, b) => a < b,
|
'<': (a, b) => a < b && !nearlyEqual(a, b),
|
||||||
'<=': (a, b) => a <= b,
|
'<=': (a, b) => a < b || nearlyEqual(a, b),
|
||||||
'>': (a, b) => a > b,
|
'>': (a, b) => a > b && !nearlyEqual(a, b),
|
||||||
'>=': (a, b) => a >= b,
|
'>=': (a, b) => a > b || nearlyEqual(a, b),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Compare: NodeDef = {
|
export const Compare: NodeDef = {
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import type { NodeDef } from '../../NodeRegistry';
|
import type { NodeDef } from '../../NodeRegistry';
|
||||||
import { asEntity, asString } from '../../values';
|
import { asEntity, asString, nearlyEqual } from '../../values';
|
||||||
import { any, bool } from '../shared';
|
import { any, bool } from '../shared';
|
||||||
|
|
||||||
export const Equals: NodeDef = {
|
export const Equals: NodeDef = {
|
||||||
type: 'Equals',
|
type: 'Equals',
|
||||||
label: 'Equals',
|
label: 'Equals',
|
||||||
category: 'data',
|
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')],
|
inputs: [any('a'), any('b')],
|
||||||
outputs: [bool('result')],
|
outputs: [bool('result')],
|
||||||
params: [],
|
params: [],
|
||||||
@@ -14,6 +15,9 @@ export const Equals: NodeDef = {
|
|||||||
const a = asEntity(inputs.a);
|
const a = asEntity(inputs.a);
|
||||||
const b = asEntity(inputs.b);
|
const b = asEntity(inputs.b);
|
||||||
if (a || b) return { outputs: { result: a === 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) } };
|
return { outputs: { result: asString(inputs.a) === asString(inputs.b) } };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -25,6 +25,16 @@ export function isEntity(value: Value | undefined): value is EntityHandle {
|
|||||||
return typeof value === 'object' && value !== null && ENTITY_HANDLE in value;
|
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. */
|
/** 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 {
|
function isValueList(value: Value | undefined): value is ValueList {
|
||||||
return Array.isArray(value);
|
return Array.isArray(value);
|
||||||
|
|||||||
@@ -685,11 +685,72 @@ const CASES: Record<string, Case[]> = {
|
|||||||
{ name: 'equal text', inputs: { a: 'a', b: 'a' }, outputs: { result: true } },
|
{ 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: '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: '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: [
|
Compare: [
|
||||||
{ name: 'less than', inputs: { a: 1, b: 2 }, params: { op: '<' }, outputs: { result: true } },
|
{ 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: '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 } },
|
{ 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 "<"',
|
name: 'no operator chosen falls back to the default "<"',
|
||||||
inputs: { a: 1, b: 2 },
|
inputs: { a: 1, b: 2 },
|
||||||
|
|||||||
Reference in new issue
Block a user