# NOTES — twelve files, and the four lines that decide what a new rule costs

Compile and run from the directory holding the sources:

```
..\..\..\.toolchain\jdk-21\bin\javac.exe -Xlint:all -d out *.java
..\..\..\.toolchain\jdk-21\bin\java.exe -cp out Main
```

`javac -Xlint:all` prints nothing and exits 0. Real output, from exactly this code:

```
== 1 - the rules are a list of values ==
  6 states x 6 event kinds = 36 cells, 8 filled, 28 refused by silence
  REQUESTED       Matched         -> MATCHED
  MATCHED         DriverArrived   -> DRIVER_ARRIVED
  DRIVER_ARRIVED  TripStarted     -> IN_PROGRESS
  IN_PROGRESS     TripCompleted   -> COMPLETED
  REQUESTED       RiderCancelled  -> CANCELLED
  MATCHED         RiderCancelled  -> CANCELLED
  DRIVER_ARRIVED  RiderCancelled  -> CANCELLED
  DRIVER_ARRIVED  WaitExpired     -> CANCELLED

== 2 - the log is the trip; the state is its last row ==
  09:00:00  REQUESTED       -> MATCHED         driver d-7 assigned
  09:03:00  MATCHED         -> DRIVER_ARRIVED  driver at the kerb
  09:06:00  DRIVER_ARRIVED  -> IN_PROGRESS     rider in, moving
  09:09:00  IN_PROGRESS     -> COMPLETED       dropped off
  state() = COMPLETED, read off the end of the log

== 3 - three requirements enforced by rows that do not exist ==
  no self-loop:        IllegalTransition: a trip in MATCHED cannot handle Matched
  no cancel row:       IllegalTransition: a trip in IN_PROGRESS cannot handle RiderCancelled
  terminal, not table: Finished: the trip is already CANCELLED; Matched can never be accepted
  Neither an if nor a switch was written for any of the three.

== 4 - the same code, two rule sets ==
  8 rows: a driver who forgot to tap arrive starts anyway: refused
  9 rows: a driver who forgot to tap arrive starts anyway: accepted -> IN_PROGRESS
  The difference is one Edge in a list. No method was opened.

== 5 - the rule the table cannot hold ==
  row alone, 1s after arrival:         CANCELLED  <- wrong, and the table agreed
  row plus guard, 299s after arrival:  refused: a trip in DRIVER_ARRIVED cannot handle WaitExpired
  row plus guard, 300s after arrival:  accepted -> CANCELLED
  clock reads: 1 after one accepted event, 1 after a WaitExpired the table refused
  Time is only consulted for events the table already permitted.
```

Two lines there are the whole lesson. Block 4 shows a rule change costing one list element. Block 5
shows a rule the same list cannot express at any length, accepting a no-show one second after the
driver pulled up.

---

## `TripRules.java` — the requirement, transcribed, and nothing else

```java
                new Edge(TripState.MATCHED, TripEvent.RiderCancelled.class, TripState.CANCELLED),
                new Edge(TripState.DRIVER_ARRIVED, TripEvent.RiderCancelled.class, TripState.CANCELLED)
```

**These two rows, and the third one above them, are the entire cancellation policy.** There are
three `RiderCancelled` rows and no fourth. "A trip cannot be cancelled once it is under way" is not
a condition anywhere in this design. It is the row that is absent.

The same trick does two more jobs. No row is a self-loop, so a duplicate `Matched` is refused with
no de-duplication code. `COMPLETED` and `CANCELLED` appear only as destinations, so a terminal state
needs no marker to be dead.

Block 1 counts the consequence: 36 cells, 8 filled. **The 28 empty cells are 28 refusals nobody
wrote, and each one is a line of code that cannot have a bug in it.**

```java
    public static List<Edge> standard() {
        return List.of(
```

The closing bracket sits on its own line so that appending a rule adds rows instead of restructuring
the call. Not quite free, and the honest version is worth stating: Java forbids a trailing comma in
an argument list, so an append also adds a comma to the row above. A line diff charges that as a
removal and a re-addition. One appended rule therefore measures 3 lines, not 2.

That is not a rounding error in this repo. It is exactly the number
`corpus/trip-state-machine/curveballs/02-start-without-the-tap/budget.json` records for a real
requirement change: `reference_diff: 3`, and its note says *"one of them is a comma"*.

## `TransitionTable.java` — the mechanism, which will not change again

```java
        TripState to = edges.getOrDefault(from, Map.of()).get(event.getClass());
        if (to == null) {
            throw new TripException.IllegalTransition(from, event);
        }
```

**`getOrDefault` rather than `get` is what makes an unreachable state safe.** A state with no
outgoing rows at all has no entry in the outer map. `get(from).get(...)` would throw
`NullPointerException` there, and a caller catching `TripException` would not catch it. The refusal
would look like a crash instead of a rule.

**This method is pure, and the whole ordering in `Trip` depends on it.** It looks a value up and
changes nothing. So a caller can find out whether an event is legal before touching the log, the
clock, or anything outside. That is why `Trip` has no rollback path.

```java
            if (clash != null) {
                throw new IllegalArgumentException("two targets for " + rule.from() + " + "
                        + rule.on().getSimpleName() + "; the table must be a function");
            }
```

**This line says the table is a function, and enforces it at construction.** Without it, two rows for
one pair mean the last one silently wins. The bug that produces is a machine that transitions
correctly until somebody reorders the list in `TripRules`, and then a trip goes somewhere else with
no code change anywhere.

```java
        built.replaceAll((state, row) -> Map.copyOf(row));
        this.edges = Map.copyOf(built);
```

A trip's rules cannot change while it is running. Both copies are needed: `Map.copyOf` on the outer
map alone would still share the inner rows with whoever built them.

## `Edge.java` — the signature that decides everything else

```java
public record Edge(TripState from, Class<? extends TripEvent> on, TripState to) {
```

**Read this as a function type: `(TripState, event kind) -> TripState`.** That signature is the bet.
Any requirement expressible as a function of those two inputs is a row and no edit anywhere. Any
requirement that needs a third input is not a row, however the table is indexed.

The `Class` key is not a trick. `TripEvent` is sealed rather than an enum, because a `Matched` has to
carry which driver, so there is no constant to key on. What identifies the kind of event is its type.

**The cost, stated once and paid in the corpus:** a `Class` key is not exhaustiveness-checked.
Nothing forces a new event to have a row.
`corpus/trip-state-machine/curveballs/01-driver-cancels/reference-patch/PATCH.md` verified this
against the real reference — three files stopped compiling and `TripRules` was not one of them. A
sixth event with no row compiles and is refused at runtime. That is a safe default, and it is the
honest limit of "make it a compile error".

## `Trip.java` — four lines, in this order, and the order is the design

```java
        TripState to = table.next(from, event);
        Instant now = clock.instant();
        guard.check(from, event, history(), now);
        log.add(new Transition(from, to, event, now));
```

**Line 1 asks the table, and it is first because it is free.** No clock, no log write, nothing
outside.

**Line 2 reads the clock exactly once, and it sits below line 1 on purpose.** Move it to the top of
the method and every refusal costs a clock read. Block 5 measures that: a `WaitExpired` refused by
the table leaves the read count at 1. With the read at the top it would be 2, and a later
transition's timestamp would be a minute out. The corpus asserts this directly, in
`corpus/trip-state-machine/curveballs/03-no-show-timeout` — the test is called
`refusedByTheTableCostsNoClockRead`.

**Line 3 is where the third input lives, and it comes after the table for a reason.** The guard is
handed the pair the table has already accepted, so a guard never has to ask whether a transition
exists. It answers a narrower question: not yet, or now.

**Line 4 is the commit, and it is one append.** Every refusal above it leaves the trip untouched, so
there is no rollback path in this class. There is nothing to roll back.

```java
    public TripState state() {
        return log.isEmpty() ? TripState.REQUESTED : log.get(log.size() - 1).to();
    }
```

No `state` field. The state is read off the end of the log, so no line here keeps two
representations of one fact in step, and no line can fail to. It also pays for itself in block 5:
"when did the driver arrive" is already recorded, so the no-show rule needs no new field.

## `WaitTimeout.java` — the rule that is not a row

```java
        Instant waitingSince = log.get(log.size() - 1).at();
        if (Duration.between(waitingSince, now).compareTo(MINIMUM_WAIT) < 0) {
```

**Line 1 reads the last entry rather than searching for the last `DriverArrived`.** The trip's state
is the last entry's `to`, and the table gives `WaitExpired` a cell only from `DRIVER_ARRIVED`. So the
last entry is necessarily the transition that put the driver at the kerb, whichever event did it.
Written this way it needs no change if a second row into `DRIVER_ARRIVED` is ever added. It also
cannot pick up the timestamp of an arrival the trip has since left and come back to.

**Line 2 is `< 0` and not `<= 0`, because the requirement says "at least five minutes".** Both
readings compile. Block 5 pins the boundary: 299 seconds is refused, 300 accepted.

**The refusal is `IllegalTransition`, not `Finished`.** The trip is live, and the very same event
will be accepted a few minutes later once time has passed. A caller deciding whether to retry needs
that distinction, which is the reason there are two exception types at all.

## The rule to take away

Before choosing between a table and a `switch`, write the requirement as a function signature and
count its inputs.

- **`(state, event) -> state`.** It is a row. Cancellation from three states, a shortcut edge, a
  backward edge into a state the trip has already been in, a whole new terminal state: all of them
  are rows. Measured in the corpus at 3 and 8 lines.
- **Anything else.** It is not a row at any price. A five-minute wait depends on the state, the
  event **and** how long the trip has sat there, which the table's signature never carried. Measured
  at 28.

The third input has a tell in the requirement's own words. "At least five minutes", "only during
happy hour", "if the purse has enough change", "no more than three times" — each of those names
something the pair `(state, event)` does not contain. `corpus/vending-machine` splits along the same
line. Its `Transitions.java` is an enum-keyed table of 7 rows, and the price check is not one of
them. The comment on the `IDLE + SELECT` row says the machine has no objection to selecting with no
money in. Then: *"the price objects, later, and that is a different concern in a different class."*

## What the compiler catches when a seventh event arrives

Adding a variant to `TripEvent` and compiling without touching anything else. Real `javac` output:

```
Main.java:166: error: the switch expression does not cover all possible input values
        return switch (event) {
               ^
1 error
```

That is `Main.narrate`, the exhaustive switch with no `default`. The compiler names the file and the
line before a test runs.

What it does not catch is the missing row in `TripRules`, because `Edge` keys on a `Class`. The new
event compiles, and every trip refuses it. Push on this point if somebody tells you a table makes
new events a compile error — half of it is true.

## The one place this design's own documentation drifted

`corpus/trip-state-machine/reference/src/TripRules.java` claims in its javadoc that a driver-side
cancellation, a re-match, **a no-show timeout** and a scheduled trip *"are rows here and nothing
else."* Curveball 03 is the counter-example to its own example. A no-show timeout is a row **and** a
guard: the row makes the pair legal in principle, and the guard decides whether it is legal yet.

The sentence is still there, and `03-no-show-timeout/reference-patch/PATCH.md` explains why. Both
earlier curveballs ship a copy of that file, so correcting the base would re-measure two verified
budgets. The note calls the fix more expensive in trust than the sentence is.

Worth sitting with, because it is the normal case rather than an oddity. The javadoc was written by
somebody who had thought hard about the design, and it was true of every requirement they had seen.
The design was fine. The sentence about the design was what aged.
