Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ history.

## Unreleased

- `types/object`: new `structurallySame`, plus `assertStructurallySame` in
`fjs/asserts` — structural comparison for proofs
[#1538](https://github.com/functionalscript/functionalscript/pull/1538)
- RTTI: new `fjs/types/rtti/data` module — a function-free, serializable,
canonical data form for schemas with `toData`, `cmp`, `equal`, `subset`, and a
data-driven `validate`
Expand Down
20 changes: 20 additions & 0 deletions fjs/asserts/module.f.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
* @module
*/

import { structurallySame } from '../types/object/structurally_same/module.f.mjs'

/**
* Marks a code path as unimplemented. Always throws.
* @type {() => never}
Expand Down Expand Up @@ -33,6 +35,24 @@ export const assertEq = (...x) => {
assert(a === b, x)
}

/**
* Asserts that `a` and `b` are structurally the same — equal leaves under the
* same shape, property order irrelevant — throwing `x` (the `[a, b]` pair, plus
* an optional third element used as an extra message) if they differ.
*
* This is the assertion to reach for when comparing two independently
* constructed values. `assertEq` is `===`, so it only ever answers "the same
* reference?" for objects, which is why proofs fell back to comparing
* `JSON.stringify` output; see `types/object/structurally_same/README.md` for
* why that is the wrong question and what this one does *not* cover.
*
* @type {(...x: readonly[unknown, unknown, unknown?]) => void}
*/
export const assertStructurallySame = (...x) => {
const [a, b] = x
assert(structurallySame(a, b), x)
}

/**
* Asserts that `a` is neither `null` nor `undefined` and returns it,
* narrowed to `T`.
Expand Down
16 changes: 15 additions & 1 deletion fjs/asserts/proof.f.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { assert, assertEq, todo } from './module.f.mjs'
import {
assert,
assertEq,
assertStructurallySame,
todo,
} from './module.f.mjs'

export const proof = {
assertPassesOnTrue: () => {
Expand All @@ -9,9 +14,18 @@ export const proof = {
assertEq(1, 1)
assertEq('x', 'x')
},
assertStructurallySamePassesOnSame: () => {
// the case `assertEq` cannot do: two separately built values
assertStructurallySame({ a: 1, b: [2, { c: 3 }] }, { b: [2, { c: 3 }], a: 1 })
assertStructurallySame(1, 1, 'with message')
},
throw: {
assertEqThrowsOnUnequal: () => assertEq(1, 2),
assertEqThrowsOnUnequal3: () => assertEq(1, 2, "message"),
assertStructurallySameThrowsOnDifferent:
() => assertStructurallySame({ a: 1 }, { a: 2 }),
assertStructurallySameThrowsOnDifferent3:
() => assertStructurallySame({ a: 1 }, { a: 2 }, 'message'),
assertThrowsDefaultMsg: () => assert(false),
assertThrowsCustomMsg: () => assert(false, 'oops'),
todoThrows: () => todo(),
Expand Down
8 changes: 6 additions & 2 deletions fjs/bnf/proof.f.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@
* @import { Rule } from './types.ts'
*/

import { assert, assertEq } from '../asserts/module.f.mjs'
import {
assert,
assertEq,
assertStructurallySame,
} from '../asserts/module.f.mjs'
import {
eof,
eofSymbol,
Expand Down Expand Up @@ -102,7 +106,7 @@ export const proof = {
() => {
const r = definedValues(notSet('a'))
const decoded = r.map(rangeDecode)
assertEq(JSON.stringify(decoded), JSON.stringify([[0, 0x60], [0x62, maxSymbol]]))
assertStructurallySame(decoded, [[0, 0x60], [0x62, maxSymbol]])
},
],
str: [
Expand Down
107 changes: 107 additions & 0 deletions fjs/bnf/todo/serialized-proof-expectations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
## Replace serialized proof expectations with structural ones

**Priority:** P4
**Status:** open

### Problem

Roughly 105 proof assertions compare `JSON.stringify(value)` against a JSON
**string literal** instead of stating the expected value directly:

| File | sites |
| ---- | ----- |
| `fjs/djs/tokenizer/proof.f.mjs` | ~14 |
| `fjs/bnf/ll1/proof.f.mjs` | 34 |
| `fjs/bnf/descent/proof.f.mjs` | 27 |
| `fjs/bnf/data/proof.f.mjs` | 4 |
| `fjs/media/json/serializer/proof.f.mjs` | 11 (serialization **is** the contract — leave alone) |

```js
const result = JSON.stringify(dm)
if (result !== '{"":{"rangeMap":[[null,64],[{"rules":[]},70]]}}') { throw result }
```

Serialization is incidental here — the proof wants "is this the dispatch map I
expect?", not "does it serialize to this text". The string form makes property
order observable, drops `undefined`-valued properties, and forces the reader to
parse JSON in their head to see what is being claimed.

`structurallySame` / `assertStructurallySame` (added in the PR that filed this
issue, see `fjs/types/object/structurally_same/README.md`) is the comparison
these sites want. The two sites where serialization was *only* a comparison
mechanism — `fjs/cas/evo/proof.f.mjs` and `fjs/bnf/proof.f.mjs:105`, both
`stringify(actual)` vs `stringify(expected)` — were converted there. These
remaining ones were not, for the reason below.

### The obstacle: `undefined`-valued properties

A mechanical rewrite of the string literal into the equivalent JavaScript
literal **does not pass**, and this is not a typo-level problem. The BNF
dispatch entries carry optional properties as *present with value
`undefined`*, which `JSON.stringify` silently drops:

```js
const dm = dispatchMap(toData(range('AF'))[0])
Object.keys(dm['']) // ['emptyTag', 'rangeMap'] — emptyTag is present, and undefined
```

So `{"":{"rangeMap":[…]}}` is a *lossy projection* of the real value. Under
`structurallySame`, `{ a: undefined }` and `{}` deliberately differ (a property
is a property), so the honest literal is
`{ '': { emptyTag: undefined, rangeMap: […] } }` — every expectation gains
`emptyTag: undefined` / `tag: undefined` noise that says nothing about the
grammar under test. That is not obviously an improvement over the JSON string,
which is why this needs a design decision rather than a mechanical pass.

A second, smaller loss: `fjs/bnf/data/proof.f.mjs`'s `emptyTagMap` expectations
(`'{"5":true,"":"e"}'`) are order-sensitive today purely because they are
strings. `structurallySame` ignores property order by design, so converting
them silently drops an assertion nobody wrote on purpose — fine if intended,
but it should be intended.

### Proposal

Decide which of these is true, then apply it uniformly:

1. **The `undefined` properties are the defect.** `dispatchMap` and friends
should not emit `emptyTag: undefined` / `tag: undefined` at all — omit the
key instead. Then the mechanical rewrite works, the expectations read
cleanly, and the data structures stop carrying properties that mean
"absent". Check what consumes `emptyTag` before changing its shape.
2. **The `undefined` properties are intended**, and the proofs should spell
them out. Verbose but honest; the expectation then documents the real value.
3. **`structurallySame` should treat an `undefined`-valued property as
absent.** This would match how the repo already treats `StringMap<T>` —
`{readonly[k in string]?: T}`, iterated with `definedEntries` /
`definedValues` precisely because an `undefined` value is not an entry
(AGENTS.md §6.2). It contradicts the semantics `structurallySame` shipped
with, so it is a breaking change to that helper and needs its own argument,
not a drive-by flip. Note that option 3 also removes the reason option 1
exists, so pick one, not both.

Option 1 is the most likely right answer — it fixes the data rather than the
comparison — but it is a change to `fjs/bnf`, not to the proofs, and should be
measured against what reads `emptyTag`.

### Tasks

- [ ] Decide between the three options above; record the reasoning in
`fjs/bnf/README.md` (option 1/2) or
`fjs/types/object/structurally_same/README.md` (option 3).
- [ ] Convert the `fjs/bnf/ll1`, `fjs/bnf/descent`, `fjs/bnf/data` and
`fjs/djs/tokenizer` expectations accordingly.
- [ ] Confirm the `emptyTagMap` expectations do not depend on property order,
or keep those specific ones as strings and say why.
- [ ] Leave `fjs/media/json/serializer/proof.f.mjs` as string comparisons —
serialized text is that module's contract.
- [ ] `npx tsc`, `fjs test`.

### Related

- `fjs/types/object/structurally_same/README.md` — the comparison these sites
should use, and what it does and does not promise.
- `fjs/cas/evo/proof.f.mjs`, `fjs/bnf/proof.f.mjs` — the two sites already
converted; both compared two *values*, so neither hit the `undefined`
problem.
- AGENTS.md §6.2 (`StringMap` / `definedEntries`) — the precedent option 3
would be aligning with.
8 changes: 6 additions & 2 deletions fjs/cas/evo/proof.f.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@
* @import { RevisionData } from './types.ts'
*/

import { assert, assertEq } from '../../asserts/module.f.mjs'
import {
assert,
assertEq,
assertStructurallySame,
} from '../../asserts/module.f.mjs'
import { pure } from '../../effects/module.f.mjs'
import { fileCas } from '../module.f.mjs'
import { sha256 } from '../../crypto/sha2/module.f.mjs'
Expand Down Expand Up @@ -65,7 +69,7 @@ export const proof = {
buildCacheEmptyStoreYieldsEmptyCache: () => {
const c = fileCas(sha256)(home)
const [, cache] = virtual(emptyState)(buildCache(c))
assertEq(JSON.stringify(cache), JSON.stringify(emptyCache))
assertStructurallySame(cache, emptyCache)
},
buildCacheSkipsNonRevisionBlob: () => {
const c = fileCas(sha256)(home)
Expand Down
9 changes: 6 additions & 3 deletions fjs/media/json/todo/remove-native-json.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,9 +145,12 @@ Consider a guard so it does not come back — the cheapest is a proof in
- [`fjs/fsc/todo/66c-emit-literals-via-owner-modules.md`](../../../fsc/todo/66c-emit-literals-via-owner-modules.md)
— already owns the source-text-quoting sites (`fjs/types/ts`,
`fjs/emergent_testing`); phase 3 defers to it rather than re-deciding.
- [`fjs/types/object/todo/structurally-same.md`](../../../types/object/todo/structurally-same.md)
— `fjs/cas/evo/proof.f.mjs:68` stringifies two values only to compare them;
`structurallySame` is the better fix for that one site.
- [`fjs/types/object/structurally_same/README.md`](../../../types/object/structurally_same/README.md)
— done: `fjs/cas/evo/proof.f.mjs` stringified two values only to compare
them, and now uses `assertStructurallySame`. The proofs that still compare a
`JSON.stringify` result against a JSON *string literal* are tracked in
[`fjs/bnf/todo/serialized-proof-expectations.md`](../../../bnf/todo/serialized-proof-expectations.md);
those are not phase-2 work either way.
- [`fjs/effects/node/todo/readjsonfile-writejsonfile-helpers.md`](../../../effects/node/todo/readjsonfile-writejsonfile-helpers.md)
— an on-hold design whose `writeJsonFile` half waits on phase 4.
- [stringify-sorted-canonical](./stringify-sorted-canonical.md) — the key-order
Expand Down
12 changes: 10 additions & 2 deletions fjs/types/object/module.f.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/**
* Plain-object helpers: safe property lookup via `at`, and conversions
* between entries and `OrderedMap`. See `./types.ts` for the
* Plain-object helpers: safe property lookup via `at`, structural comparison
* via `structurallySame`, and conversions between entries and `OrderedMap`.
* See `./types.ts` for the
* `OptionalMap`/`RequiredMap`/`StringMap`/`Entry`/`OneKey`/`SingleProperty`/
* `NotUnion` type-level API.
*
Expand All @@ -16,6 +17,13 @@ import { entries as mapEntries, fromEntries as mapFromEntries } from '../ordered
/** @import { OrderedMap } from '../ordered_map/types.ts' */
/** @import { StringMap, Entry } from './types.ts' */

/**
* `structurallySame` is implemented in a dependency-free leaf so `fjs/asserts`
* can use it without the cycle `asserts -> object -> nullable -> asserts`; see
* `./structurally_same/README.md`. This module is its public home.
*/
export { structurallySame } from './structurally_same/module.f.mjs'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Add the new public APIs to the CHANGELOG

This code change exports structurallySame and assertStructurallySame as new public APIs, but the commit has no entry under CHANGELOG.md's Unreleased section. That omits the feature from release notes and from the changelog-driven versioning process; add the required short entry with the real PR number.

AGENTS.md reference: AGENTS.md:L1194-L1203

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already done — the entry landed in 025acec, one commit after the 8723489 this review ran against. AGENTS.md §8.3 has the entry created after the PR exists so it can cite the real number, so the gap between the two commits is expected.


Generated by Claude Code


const { getOwnPropertyDescriptor, fromEntries: objectFromEntries } = Object

/** @type {(name: string) => <T>(object: StringMap<T>) => Nullable<Exclude<T, undefined>>} */
Expand Down
65 changes: 65 additions & 0 deletions fjs/types/object/structurally_same/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Structurally same

## Why this is its own module

`structurallySame` is the comparison behind `assertStructurallySame` in
[`fjs/asserts`](../../../asserts/module.f.mjs), and it is also an ordinary
object helper that belongs on [`fjs/types/object`](../module.f.mjs). Those two
homes cannot both be the implementation: the object module imports
`types/nullable`, which imports `fjs/asserts`, so an assertion module importing
the object module would close the runtime cycle
`asserts -> object -> nullable -> asserts`.

So the implementation lives here, in a leaf that imports **nothing**, and the
two consumers reach it from opposite directions — `fjs/asserts` imports it
directly, and `fjs/types/object` re-exports it as part of its public API. Adding
any import to this module re-opens the cycle; keep it a leaf.

## What it compares, and what it does not

The contract is FunctionalScript data — primitives, arrays, and record-like
objects:

- `Object.is` decides first, so `NaN` is the same as `NaN`, `0` and `-0` differ,
and a value is trivially the same as itself.
- Arrays match arrays of equal length, elementwise. An array never matches a
non-array.
- Other objects match on their own enumerable string properties as a *set* —
order is not part of the structure — with every value compared recursively.
A property whose value is `undefined` is a property, so `{ a: undefined }` and
`{}` differ.

The signature takes `unknown` because assertion and parsing boundaries have
nothing narrower to offer, but a wide input type is not a promise of wide
semantics. A date, map, set, or typed array is compared **only** by its own
enumerable string properties, which for most of them is no properties at all —
two different `Date`s read as the same. Prototypes, property descriptors,
symbol keys, and getters are all invisible here. A caller needing any of those
needs a different comparison, not a flag on this one.

There is no cycle detection: a self-referential value recurses until the stack
runs out. FunctionalScript data is acyclic, so a seen-set would tax every real
comparison to catch a case that cannot occur.

Array comparison likewise assumes **dense** arrays. `Array.prototype.every`
skips a sparse array's holes, so a hole-bearing first operand would compare
vacuously equal in one direction and not the other. That asymmetry is
unreachable rather than handled: FunctionalScript has no way to build a sparse
array — `new Array(n)` is not part of the language — so the input cannot occur,
and spreading every array into a dense copy to defend against it would cost an
allocation per comparison for a value that cannot exist. Callers reaching this
from plain JavaScript with a hand-built sparse array are outside the contract,
like the host objects above.

## Why proofs should prefer it to `JSON.stringify`

Proofs reached for `assertEq(JSON.stringify(a), JSON.stringify(b))` because no
structural comparison existed. Serialization answers a different question and
drags in semantics the proof did not ask for: property order becomes
observable, `undefined`-valued properties vanish, `NaN` and the infinities
collapse to `null`, `-0` becomes `0`, `bigint` throws, and both sides allocate
strings only to be thrown away.

Keep a string comparison where the serialized text *is* the contract — a
serializer's own proofs, or an API that returns text. Everywhere else, state the
expected value directly and compare it with `assertStructurallySame`.
59 changes: 59 additions & 0 deletions fjs/types/object/structurally_same/module.f.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/**
* Structural comparison for FunctionalScript data.
*
* `structurallySame` answers "do these two independently constructed values
* have the same shape and the same leaves?" — the question proofs really ask
* when they compare a computed value against an expected one. It is scoped to
* FunctionalScript data (primitives, arrays, and record-like objects) and
* deliberately says nothing about dates, maps, sets, typed arrays, prototypes,
* or property descriptors; see the README for what it does *not* promise.
*
* This module is a dependency-free leaf on purpose. `fjs/asserts` needs the
* comparison for `assertStructurallySame`, and the public object module
* (`../module.f.mjs`) reaches `fjs/asserts` through `types/nullable`, so
* importing anything here would close the cycle
* `asserts -> object -> nullable -> asserts`. Keep it importing nothing.
*
* @module
*/

const { entries, is } = Object

/**
* Compares two values structurally.
*
* - `Object.is` decides first, so `NaN` equals itself, `0` and `-0` differ, and
* an object is trivially the same as itself.
* - Anything else that is not a non-null object differs.
* - Arrays match arrays of the same length whose elements match pairwise; an
* array never matches a non-array. Arrays are assumed dense — FunctionalScript
* cannot build a sparse one; see the README.
* - Other objects match when their own enumerable string properties form the
* same set — order is irrelevant — and every property's value matches. A
* property whose value is `undefined` is a property: `{ a: undefined }` and
* `{}` differ.
*
* There is no cycle detection: a self-referential value recurses until the
* stack runs out. FunctionalScript data is acyclic, and adding a seen-set would
* cost every ordinary comparison for a case that cannot arise.
*
* @type {(a: unknown, b: unknown) => boolean}
*/
export const structurallySame = (a, b) => {
if (is(a, b)) { return true }
if (
typeof a !== 'object' || a === null ||
typeof b !== 'object' || b === null
) { return false }
if (a instanceof Array) {
return b instanceof Array
&& a.length === b.length
&& a.every((v, i) => structurallySame(v, b[i]))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Visit every array index during comparison

When the first operand is sparse, Array.prototype.every skips its holes, so this equality predicate becomes asymmetric and can accept unequal arrays: structurallySame(new Array(1), [1]) returns true, while reversing the operands returns false. Iterate all indices rather than only present elements so sparse inputs cannot bypass comparisons.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FunctionalScript doesn't allow new Array(1)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, and thanks — I'd started down the wrong path here. I had pushed a [...a].every(...) spread plus proof cases built on new Array(1); since the language can't produce a sparse array, that was defending against an unreachable input and writing non-FunctionalScript into a proof. Both are reverted in cd1b73d — the array branch is back to plain a.every(...), and the proof covers [undefined] (an ordinary element) instead.

The asymmetry is now recorded as out of scope rather than silently dropped, in structurally_same/README.md alongside the other non-promises (host objects, cycles): sparse arrays cannot occur in FunctionalScript data, so spreading every array into a dense copy would cost an allocation per comparison for a value that cannot exist.

new Map(...) in the same module stays — that one has precedent in types/map and djs/serializer, and the lang spec uses it.

npx tsc clean, 2584 tests pass, structurally_same/module.f.mjs still 100% lines/branches.


Generated by Claude Code

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle deeply nested data without recursive calls

When two distinct but equal values contain a few thousand nested arrays, this recursive call throws RangeError: Maximum call stack size exceeded instead of returning a boolean. These are valid acyclic FunctionalScript values, and fjs/media/json/parser/proof.f.mjs explicitly verifies that the parser can produce 5,000-level nesting, so comparing two such parser results with the new assertion regresses the supported deep-data path. Use an explicit comparison worklist rather than the JavaScript call stack.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Memoize repeated substructure comparisons

When independently constructed acyclic values share substructure, this traversal re-compares the same object pair once per incoming reference. For example, repeatedly forming a = [a, a] and an independent b = [b, b] creates only O(n) arrays but makes structurallySame(a, b) perform O(2^n) comparisons (depth 24 already takes about a second). Shared immutable data is within the documented FunctionalScript-data contract, so track previously compared object pairs to keep comparison proportional to the actual graph size.

Useful? React with 👍 / 👎.

}
if (b instanceof Array) { return false }
const ae = entries(a)
/** @type {ReadonlyMap<string, unknown>} */
const bm = new Map(entries(b))
return ae.length === bm.size
&& ae.every(([k, v]) => bm.has(k) && structurallySame(v, bm.get(k)))
}
Loading
Loading