91e16d69

Virtual assertions

Implements `VIRTUAL-ASSERTIONS-PLAN.md` from this branch, which is deleted by
the last commit now that the work exists.

## The problem

`+ assert` cannot see virtual postings. That is deliberate and consistent —
`balanceTransaction` feeds only real postings into `balancesForAssertions`,
`checkEntryAssertions` reads the without-virtual map, and
`balance/balanced/virtual-assertion.cent` pins it — but it leaves an account
declared `virtual-only` with **no assertable balance at all**.

The failure mode was worse than a refusal. Asserting such an account reported

```
The actual balance has no amount in the asserted currency.
Maybe there is a mistake with the currency?
```

which reads as a typo in the ledger rather than as a form that can never work
there.

## What this adds

```centjes
2025-01-27
  | I bought a coffee.
  * assets:cash -5.00 USD
  * expenses:coffee 5.00 USD
  ! expenses:vat 0.50 USD
  + assert expenses:coffee = 5.00 USD
  + assert virtual expenses:vat = 0.50 USD
```

Plain `+ assert` keeps meaning exactly what it meant. `--virtual` still changes
what you see, not what an assertion means.

## Worth a look

**The scope is lexed as one compound token** in the `<extra>` start code. That
leaves the `assertion` production untouched, so this composes with #5 for free:

```centjes
+ assert virtual assets:broker = +1 SWDA lot @ 500 EUR
```

The scope sits on the extra, the lot sits on the commodity, and neither grammar
production knows about the other.

**An account named exactly `virtual` cannot use the plain form.**
`@assert_virtual` is `"assert virtual "`, fifteen characters against seven for
`"assert "`, so on `+ assert virtual = 5 CHF` longest-match takes the compound
token and swallows the account name, giving a parse error. This was a
regression, caught in review: that ledger reports `Valid` on `master`.

It is accepted rather than fixed, the way a currency cannot be called `lot`.
The collision cannot be avoided by lexing `virtual ` as a keyword inside
`<assertion>` either -- that fails on the same input for the same reason -- and
the alternatives are a grammar production that reconstitutes the swallowed name,
or a hyphenated keyword in a language whose keywords are space separated. The
restriction is narrow: `assets:virtual`, `virtual:sub` and `virtualx` all work,
as does `+ assert virtual virtual = 5 CHF`.

**`AssertionScope` is a two-constructor type, not a `Bool`**, and it sits on
`Assertion` in both `Module` and `Ledger` rather than on `ExtraAssertion` where
the keyword syntactically lives, so `Compile` copies a field instead of moving
one between types.

**The behavioural change is one map selection** in `checkEntryAssertions`.
`checkAssertion` already took the balances as an argument.

**It is legal on any account**, whatever its virtual posting policy. The keyword
names the view, not the kind of account. On an account that forbids virtual
postings the two forms are equivalent, which makes `assert virtual` redundant
there, not wrong.

## Two silent holes closed

- **`CE_REAL_ASSERTION_NOT_ALLOWED`** — a plain assertion on a `virtual-only`
  account. Its real balance is empty by construction, so the assertion is either
  trivially true at zero or always false, and carries no information either way.
  This is what used to produce the misleading message above.
- **`CE_UNDECLARED_ACCOUNT` now covers assertions.** `compileTransactionAssertion`
  took only `currencies`, so it never looked at the account name, and
  `checkAssertion` defaulted a missing account to zero. An assertion naming an
  account that does not exist **passed silently**: a ledger declaring only
  `assets:cash` and asserting `assets:typoo = +0.00 CHF` reported `Valid`.
  Threading the accounts map in for the first error made the second a one-line
  fix, which is why they are in one change.

Both have a fixture and a rendered `.err` golden. Both fixtures start life
*passing*, which is their red state, since `CompileSpec` requires failure.

## Deliberately unchanged

- Account-type assertions stay real-only. A `virtual-only` liability holding a
  positive virtual balance stays legal, as today.
- `balance/balanced/virtual-assertion.cent` is untouched. It belongs to the old
  behaviour and is the regression test for the decision that plain `assert`
  keeps meaning what it means — which is why the new fixture is
  `assert-virtual.cent` and not the other way round.
- Out of scope: virtual-aware account-type assertions, virtual-aware
  `assert currency`, and `net-worth` counting virtual postings.

## Checks

`nix flake check` passes. All five suites pass: 777 in `centjes-gen` (up from
775 on `lot-prices`), 28 in `centjes-switzerland`, 3 + 13 + 3 elsewhere.

Suite timing

Time to Start Worker time Duration Time to finish Idle
Config 0s 1s 1s 1s 0s
Eval 1s 13s 13s 14s 0s
Build 13s 0s 0s 13s 0s
Suite 0s 14s 14s 14s 0s

Timeline

0s10s