🌐 ν•œκ΅­μ–΄λ‘œ 읽기

05 β€” Lab notebook: the working habits

Where we are

Chapter 04 ends at 8c5fbb1a ("Add deserialization.") with monadic IO, a working IO.serialize/IO.deserialize pair, and a documented, reproducible crash the moment deserialization touches a shared or cyclic label. But 8c5fbb1a is not the first commit of this session β€” by the time it lands (16:17:09 on 2023-06-02), four other commits have already gone in, earlier the same afternoon: ef082bbe (14:23:17), 8c66d4dd (14:28:31), c0fb9a91 (14:30:13), and b0915dcf (16:16:53, sixteen seconds before 8c5fbb1a itself). Chapter 04 named all four and explicitly deferred them: "Three more commits land the same day and are chapter 05's story, not this one's: ef082bbe/8c66d4dd/c0fb9a91 add independent -v verbosity flags to both uhs (the compiler) and eval (the runtime), and b0915dcf moves the library .uhs modules into a lib/ subdirectory and adds a -i<dir> module search path ..." None of the four touch IO, serialization, or any other feature chapter 04 was tracking β€” they're tooling, and this chapter is where tooling gets its due.

So this chapter's material spans two things at once: those four commits (which precede 8c5fbb1a in the commit graph, git log --oneline --reverse ccb31388..8018eec8 puts them at positions 40–43, 8c5fbb1a at 44), and the fifteen commits of the range 00-overview.md's timeline table actually assigns this chapter, 8c5fbb1a..8018eec8 (6c59dd36 through 8018eec8, all landing between 16:23 and 19:10 the same evening β€” under three hours). By the end of that evening the project has bin/uhs and bin/eval as the binaries' real names (no longer Main), a lib/ directory holding four modules with a -i search path to find them, .hs extensions throughout (renamed from .uhs), a three-module automated test harness wired into make test, a first README.md, and a -H<N> flag to size the heap at run time instead of only at compile time. None of it is a new language feature. All of it is what makes the previous four chapters' features usable, checkable, and explicable to someone who isn't the author β€” and that shift, from "make it work" to "make it a project," is what this chapter tracks.

What happened

Habit 1: observability first β€” ef082bbe, 8c66d4dd, c0fb9a91

Three commits, seven minutes apart, all on the afternoon of 2023-06-02, each adding one more layer of "show me what's happening":

ef082bbe ("Use a flag to be verbose.") gates eval's existing diagnostic trailer β€” the main returns ... dump, the node size=... line, the reduction/GC/cell counters β€” behind a new -v flag, off by default. Before this commit that trailer printed unconditionally on every run; a program's own output (its actual putChar/print effects) was always followed by internal instrumentation nobody but the author, mid-debugging, wanted to see every time. The whole change is one main, quoted whole (ef082bbe:eval.c, lines 989–1018):

int
main(int argc, char **argv)
{
  int verbose = 0;
  if (argc > 1 && strcmp(argv[1], "-v") == 0) {
    argc--;
    argv++;
    verbose = 1;
  }
  if (argc != 2)
    ERR("no file");
  init_nodes();
  FILE *f = fopen(argv[1], "r");
  if (!f)
    ERR("file not found");
  NODEPTR n = parse_top(f);
  fclose(f);
  PUSH(n); gc(); n = TOP(0); POP(1);
  int start_size = num_marked;
  //pp(stdout, n);
  n = evalio(n);
  if (verbose) {
    printf("\nmain returns ");
    pp(stdout, n);
    printf("node size=%ld, heap size=%ld\n", NODE_SIZE, heap_size);
    printf("%d reductions, %d GCs, max cells used %d\n", num_reductions, num_gc, max_num_marked);
    printf("%d cells at start\n", start_size);
  }
  exit(0);
}

Five minutes later, 8c66d4dd ("Add a -v flag.") does the same thing on the compiler side, but it's a bigger change because compile never had a verbosity parameter to gate on β€” it always printed. Compile.hs grows a Bool argument threaded through three functions (8c66d4dd:Compile.hs, lines 18–19 and 24, quoted):

compile :: Bool -> IdentModule -> IO [LDef]
compile verbose nm = do
compileModuleCached :: Bool -> IdentModule -> StateT Cache IO CModule

with every one of its three putStrLn "importing ..." trace lines wrapped in when verbose $. Main.hs picks the flag off the command line and passes it through (8c66d4dd:Main.hs, lines 8–15, quoted whole):

main :: IO ()
main = do
  args <- getArgs
  let verbose = "-v" `elem` args
      mn = case filter ((/= "-") . take 1) args of
             [s] -> s
             _ -> error "Usage: compile [-v] ModuleName"
  ds <- compile verbose mn

and the same flag now also gates the pre-8c66d4dd behavior of dumping every compiled definition's combinator form and the whole linked program to stdout before writing out.comb β€” previously unconditional, now when verbose $ do .... Two minutes after that, c0fb9a91 ("More verbosity.") turns eval's one-shot -v into a counter and adds a second level: the if (argc > 1 && ...) becomes a while loop so -v -v is legal and increments verbose twice, and verbose > 1 gates a call to pp(stdout, n) β€” printing the entire loaded program graph, before any reduction happens β€” that the previous commit had left as a commented-out //pp(stdout, n); one line above where it now runs uncommented (c0fb9a91:eval.c, lines 989–1008, quoted; the if (verbose > 1) pp(stdout, n); on the last two lines is the whole addition beyond widening verbose to an int):

int
main(int argc, char **argv)
{
  int verbose = 0;
  while (argc > 1 && strcmp(argv[1], "-v") == 0) {
    argc--;
    argv++;
    verbose++;
  }
  if (argc != 2)
    ERR("no file");
  init_nodes();
  FILE *f = fopen(argv[1], "r");
  if (!f)
    ERR("file not found");
  NODEPTR n = parse_top(f);
  fclose(f);
  PUSH(n); gc(); n = TOP(0); POP(1);
  int start_size = num_marked;
  if (verbose > 1)

Three commits, three tools (eval twice, uhs once), one pattern each time: something that used to print always, or never, now prints on request, at a level the caller controls. Try It, Exercise 1 runs all three levels on the same program and shows exactly what each one adds.

Habit 2: tests as you go β€” 6ac9c791, with roots two commits earlier

6ac9c791 ("Add some testing.") is not where automated testing in this project starts from nothing β€” 9b6e23c8, two commits earlier the same evening, already added tests/Fac.uhs (soon renamed tests/Fac.hs by 73a427d7), a small module computing fac 6 and fst res + snd res where res = (fac 10, nfib 30). What 6ac9c791 actually is: a Makefile loop that compiles each test module, runs it, and diffs the output against a checked-in golden file β€” nothing more elaborate than that. The whole harness, tests/Makefile at this commit, quoted whole:

UHS=../bin/uhs -i../lib
EVAL=../bin/eval

tests:
	$(UHS) IOTest && (echo q | $(EVAL) out.comb > IOTest.out) && diff IOTest.ref IOTest.out
	$(UHS) StringTest && $(EVAL) out.comb > StringTest.out && diff StringTest.ref StringTest.out
	$(UHS) Fac && $(EVAL) out.comb > Fac.out && diff Fac.ref Fac.out

Three lines, three modules, three reference files β€” IOTest.ref, StringTest.ref, and Fac.ref are all new in this commit, checked-in alongside the Makefile lines that exercise them. Each line compiles a module to out.comb, runs it, redirects stdout to a .out file, and diffs that against the .ref file checked into git; make's own && chaining means the recipe line β€” and the whole make test β€” fails loudly the moment either the compile or the diff does. There is no test framework: no assertion library, no test runner beyond make itself, no structured pass/fail report beyond diff's exit code and whatever it prints. IOTest's line has one extra piece worth noticing, (echo q | $(EVAL) out.comb > IOTest.out) β€” IOTest.hs calls getChar once, so running it non-interactively needs something on stdin, and echo q supplies it (6ac9c791 also renames IOTest.hs's scratch files, test.txt/test2.txt, to test.tmp/test2.tmp, matching a .gitignore pattern for build artifacts the next commit, 110bcc94, adds). The .ref/.out/diff shape here β€” golden output, byte comparison, no tolerance for drift β€” is exactly the harness Try It, Exercise 2 extends with one more test in the same style.

Habit 3: write the README when the project becomes real β€” 37e44acd and same-day "More text"

37e44acd ("Add README") is the first README.md this project has ever had β€” fifty commits and just under eight days after ccb31388. It is three lines, quoted in full (37e44acd:README.md):

# Micro Haskell
This directory contains an implementation of a very small subset of Haskell.
It uses combinators for the runtime execution.

That's the whole commit β€” one new file, three insertions, nothing else touched. What's notable is less the content than the timing: it lands at 16:44:57, immediately after the test harness (6ac9c791, 110bcc94) is in place and passing, and well after the language, compiler, and runtime already do real work. The README doesn't introduce the project β€” it documents a project that has, by this point, already earned documentation.

It then grows in four more commits the same evening, each named "More text" or its close variants, and the order the author fills it in is itself informative:

  1. aae0eee2 (17:01:29, "More text.") adds ## Language (a bullet list of what the language has: variables, application, lambda without pattern matching, case/let with their own listed restrictions, tuples, list syntax, qualified do, data declarations, "type signatures that are ignored") and ## Compiler with a one-line description of each of the six compiler modules. The very first substantive content after the three-line intro is a list of what the language can't do yet, stated as plainly as what it can.
  2. 04f0840b (17:09:37, "More text.") adds a new file, Example.hs (a fac function plus a main that prints four factorials), and a ## Example section in the README that reproduces that file's source verbatim inside a fenced code block, followed by build instructions ("make sure the binaries are built... compile the file by bin/uhs Example... run the combinator file by bin/eval out.comb").
  3. 91a8ff34 (17:11:02, "More text") tightens that prose ("The compile" β†’ "Then compile", "Run" β†’ "Finally, run") and adds the one thing the previous commit left out: the actual expected output, Some factorials / [1,2,6,3628800].
  4. 0bfdcd18 (17:14:21, "Fix some mistakes.") fixes the build command itself β€” bin/uhs Example becomes bin/uhs -ilib Example, because without -ilib the compiler can't find Prelude/List/IO/ String in their new lib/ home (b0915dcf's path flag, this chapter's opening habit). The README's own worked example didn't actually run as first written; the fix lands under five minutes later, the next commit touching the file.

Two more "More text" commits land later that evening once other feature work (the heap-size flag, below) is done: 60343934 (18:35:10) adds a ### Features note about serialize/deserialize preserving sharing and cycles, closing on its one caveat ("The only exception to this is file handles, which cannot be serialized"); 3cdbb820 (18:51:39) adds a ## Libraries paragraph. So the order the author wrote these sections in β€” commit order, not the order they sit in the finished README β€” is: what the language is β†’ how the compiler is organized β†’ a runnable worked example, corrected once it's shown to not actually run β†’ what the runtime can serialize, caveat included β†’ what the libraries cover. Language and workflow first; the newest, most delicate feature (cyclic serialization) last, and honestly caveated rather than oversold.

Habit 4: rename late β€” 73a427d7

Before 73a427d7 ("Rename files to .hs"), every source file in this project β€” Prelude, List, IO, String, the test modules β€” carries the extension .uhs, not .hs. 73a427d7's diffstat is the whole story (73a427d7 --stat, quoted):

 Compile.hs                              | 2 +-
 Makefile                                | 5 ++++-
 lib/{IO.uhs => IO.hs}                   | 0
 lib/{List.uhs => List.hs}               | 0
 lib/{Prelude.uhs => Prelude.hs}         | 0
 lib/{String.uhs => String.hs}           | 0
 tests/{Fac.uhs => Fac.hs}               | 0
 tests/{IOTest.uhs => IOTest.hs}         | 0
 tests/{ListTest.uhs => ListTest.hs}     | 0
 tests/Makefile                          | 5 +++++
 tests/{StringTest.uhs => StringTest.hs} | 0
 11 files changed, 10 insertions(+), 2 deletions(-)

Eight files renamed with 0 lines changed β€” pure git mv, no content touched. The other three lines in the diffstat, Compile.hs, Makefile, and tests/Makefile, carry the actual code: one line in Compile.hs, where the hardcoded extension the module loader appends to a module name flips from .uhs to .hs (compileModule's let fn = nm ++ ".uhs" becomes let fn = nm ++ ".hs"), plus a test: target added to the top-level Makefile; tests/Makefile itself is new here too β€” a five-line file with one rule, $(UHS) IOTest.hs && $(EVAL) out.comb > IOTest.out && diff IOTest.ref IOTest.out β€” the test harness's very first form, refined into the three-module loop Habit 2 describes by the very next commit, 6ac9c791.

uhs β€” "micro Haskell," or close to it, the name the compiler binary itself keeps for the rest of this chapter's range (bin/uhs, set one commit earlier by 9b6e23c8) β€” was, until this commit, also the extension every source file carried, a private in-house dialect marker. The rename happens after do-notation, IO, serialization, and Maybe/Either are all in place β€” after four chapters of "how close is this to Haskell" have already been answered, mostly, "close" β€” and, within this chapter's own session, before the README (37e44acd, eleven minutes later) that will go on to describe the result. The file extension is the last thing about the language itself to change, not the first: it follows the language's own claim on the name, rather than asserting it in advance.

Habit 5: knobs for real constraints β€” 0b97ab3e

0b97ab3e ("Add a flag to set the heap size.") turns eval's heap capacity from a value fixed at compile time β€” #define HEAP_CELLS 100000, unchanged by this commit, still the default β€” into something overridable per run, with a new -H<N> flag (0b97ab3e:eval.c, lines 1005–1021, quoted):

int
main(int argc, char **argv)
{
  int verbose = 0;
  char *fn = 0;
  
  argc--, argv++;
  while (argc > 0 && argv[0][0] == '-') {
    if (strcmp(argv[0], "-v") == 0)
      verbose++;
    else if (strncmp(argv[0], "-H", 2) == 0)
      heap_size = atoi(&argv[0][2]);
    else
      ERR("Bad flag");
    argc--;
    argv++;
  }

The same commit also generalizes argument parsing from "exactly one positional argument, the file name" to "zero or one, defaulting to out.comb" β€” a second, smaller convenience change riding along with the flag.

What actually motivated it is less certain than the mechanism, and it's worth being honest about that rather than inventing a tidy story: the commit message is bare, and nothing in the diff or the surrounding commits states a specific failure it fixes. Checked directly β€” every run in this chapter's Try It and in the existing test suite stays well under the 100000-cell default: the Try It program below (Exercise 1) peaks at 140 cells; the harness's own Fac test, run separately at this commit with -v, reports 164 cells at start and a peak of 202 cells mid-run, for its whole fac/nfib 35 computation β€” so nothing in the committed test suite was visibly hitting the ceiling. Pushing on it directly, in a throwaway build at this commit, does turn up a real limit, just not this one: a small Haskell-side program that eagerly builds a list via ordinary self-recursion (n : upto (n - 1)) and sums it runs fine at 1,000 elements (500500, correct) and segfaults, reproducibly, at 5,000 (the exact threshold between the two wasn't narrowed further) β€” a C call-stack overflow from non-tail recursion in eval's own graph walk, not a heap-cell shortage, and -H does nothing for it. The one connection the timeline does support: -H lands at 18:34:52, and seventeen minutes later 3cdbb820 adds tests/Nfib.hs, a deliberately heavier benchmark than anything run before it ("Typical nfib/s is 10M", main = print (nfib 38), with a new make time target built for it). Whether -H was written because of that heavier workload or just shortly before planning it isn't something the commits themselves settle β€” but a compile-time-only heap constant is exactly the kind of thing that stops being convenient the moment the programs being run start actually growing, and this session is visibly in the middle of making them grow.

Habit 6: cleanup as first-class work β€” 6c59dd36, 9b6e23c8, 28d53dab

Three separate commits this evening do nothing but tidy, each on its own, not bundled with any feature:

6c59dd36 ("Update"), the very first commit of this chapter's git-range proper, rewrites the top-level Makefile end to end. The Makefile it replaces still reads (6c59dd36^:Makefile, quoted whole):

test:	eval nfib.comb
	./eval nfib.comb

eval:	eval.c
	gcc -Wall -O3 eval.c -o eval

nfib.comb:	Lam
	./Lam > nfib.comb

Main:	*.hs
	ghc Main.hs -o Main

clean:
	rm -f *.hi *.o eval Lam *.comb

nfib.comb: Lam builds nfib.comb by running ./Lam, the hand-embedded-Haskell prototype chapter 04's notebook margin records as deleted outright by 8b39ba55 ("Remove junk.") — but the rule was already dead before that deletion, and for longer: 3246e2a6 ("Implement monadic IO."), the commit that opens chapter 04, had already replaced the Makefile's Lam: Lam.hs / ghc Lam.hs -o Lam rule with Main: *.hs / ghc Main.hs -o Main (visible above, both still present) without touching or removing the nfib.comb: Lam line that depended on the rule it had just replaced. From that moment — about twenty-one hours before 8b39ba55 deletes Lam.hs itself, and just under twenty-four hours before 6c59dd36 — there was no recipe left in the Makefile that could produce a Lam binary at all; 8b39ba55 only removed the source the (already unreachable) rule would have compiled. 6c59dd36 is the first commit to notice and remove the dead nfib.comb/Lam lines, in the course of introducing a bin/ output directory and moving the Main/eval targets under it as $(BIN)/Main/$(BIN)/eval — the Main→uhs rename itself is the next commit's work.

9b6e23c8 ("Better names.") follows two minutes later and is the commit that actually picks the name: the Makefile's $(BIN)/Main target becomes $(BIN)/uhs; Main.hs's usage-error string, generic and stale ("Usage: compile [-v] ModuleName" β€” "compile," not any name this project's binaries actually carry), becomes "Usage: uhs [-v] [-iPATH] ModuleName" β€” naming the just-chosen binary, and picking up the -iPATH flag b0915dcf had already added without updating this message β€” and .gitignore drops its now-stale Lam/eval/Main entries in favor of bin/*.

28d53dab ("Clean up"), the second-to-last commit of the evening, at 19:10:00, removes one function, restOfInput, from ParserComb.hs β€” four lines plus its export β€” with no evidence anywhere in this session's diffs that it was ever called. Its own commit, its own line in the log, about eighteen minutes after the last feature commit (3cdbb820) and seven seconds before the last one (8018eec8, "Add null") β€” dead code gets removed once there's a moment free to remove it, not folded silently into whatever feature commit happened to be nearby.

Why it matters

None of the six habits above are specific to Micro Haskell, or to compilers, or even to this particular language of choice. A verbosity flag that's off by default and stacks with repetition (-v, -v -v) is a decision anyone writing a CLI tool can make on day one, for free, before there's anything worth being verbose about yet. A test harness that's nothing more than "compile, run, diff against a checked-in file, let make's own exit-code chaining decide pass or fail" needs no framework, no dependency, and no more code than 6ac9c791's three Makefile lines β€” the bar for "have automated tests at all" is far lower than the bar most projects set for themselves before clearing it. A README written after the thing it describes already works, rather than as an aspirational plan beforehand, is a small discipline with an outsized effect on whether the documentation stays honest β€” and this session shows it isn't automatically honest even then: the worked example in 04f0840b didn't actually run until 0bfdcd18 fixed it under five minutes later, caught (one has to assume) by someone actually trying the command as written. A file extension that only changes once the code underneath it has grown into what the extension claims is a small act of intellectual honesty most projects skip, naming things aspirationally from the start instead. A knob for a constant that used to require a rebuild to change costs a handful of lines and pays for itself the first time someone needs a bigger number without needing a compiler on hand. And cleanup β€” a stale Makefile rule, a leftover .gitignore entry, one unused function β€” treated as its own commit rather than swept in wherever convenient, is what keeps a fast-moving project's incidental debt from compounding silently. Every one of these is adoptable, unchanged, on the first day of any project, in any language β€” which is the entire reason this chapter exists in a book that is otherwise about how a specific compiler got built.

Notebook margin

This chapter's margin is a fact about the whole project's history rather than a note on any one commit: which commit messages recur, and how. The exact command, and its real output:

$ git log --format=%s ccb31388..8018eec8 | sort | uniq -c | sort -rn | head
      3 More text.
      2 New optimiser.
      1 Various small improvements.
      1 Various little fixes,
      1 Use 'primitive'
      1 Use file handle for printing.
      1 Use a flag to be verbose.
      1 Update
      1 Small improvements
      1 Rename files to .hs

Every one of "More text."'s three occurrences is this chapter's README sequence (aae0eee2, 04f0840b, 60343934 β€” the fourth README commit, 91a8ff34, is spelled without the trailing period and so doesn't collapse into the same bucket by this exact-string count, a reminder that sort | uniq -c counts strings, not intent). "New optimiser." (b4e44ded, 9cf1e937) is earlier work, outside this chapter's range, turned up only because the command runs over the whole book so far (ccb31388..8018eec8, chapters 01–05). "Use 'primitive'" is 95ff6a34, chapter 03's closer; "Various little fixes," β€” with the trailing comma left in, 1a2f7b7a β€” is chapter 04's. Nowhere in this list, or in the full log this table samples from, is there a feat:/fix:/chore: prefix of any kind: every message here is a plain, present-tense or past-tense English sentence, occasionally without a full stop, occasionally (see above) inconsistently punctuated even by its own author across near-identical messages. The habits this chapter documents don't show up as a taxonomy in the log; they show up in what the commits actually touch.

Try it

Anchor: 37e44acd ("Add README"), the commit that gives this session its first README and, by this point, already has the .hs rename (73a427d7), the test harness's Fac/IOTest/StringTest trio (6ac9c791), and the lib/-plus--i module path (b0915dcf) all in place. Convention (see 00-overview.md):

git worktree add /tmp/mhs-37e44acd 37e44acd
cd /tmp/mhs-37e44acd

Build note: same GHC-9.8/mtl issues chapters 02–04 hit. Build by hand, into bin/, matching this commit's own Makefile layout:

mkdir -p bin
nix develop $MICROHS -c ghc Main.hs -o bin/uhs

fails first with ParserComb.hs's ap-not-in-scope error; add, after ParserComb.hs's existing import Control.Monad.State.Strict:

import Control.Monad(ap)

then with Compile.hs's when-not-in-scope error; add, after Compile.hs's existing import Control.Monad.State.Strict:

import Control.Monad(when)

With both one-line patches, the build succeeds (harmless -Wx-partial warnings about head/tail omitted):

[1 of 7] Compiling ParserComb       ( ParserComb.hs, ParserComb.o )
[2 of 7] Compiling Parse            ( Parse.hs, Parse.o )
[3 of 7] Compiling Exp              ( Exp.hs, Exp.o )
[4 of 7] Compiling Desugar          ( Desugar.hs, Desugar.o )
[5 of 7] Compiling Compile          ( Compile.hs, Compile.o )
[6 of 7] Compiling Main             ( Main.hs, Main.o )
[7 of 7] Linking bin/uhs

Build the runtime the same way:

nix develop $MICROHS -c gcc -Wall -O3 eval.c -o bin/eval

Every bin/uhs invocation below needs -ilib to find Prelude/ List/IO/String under lib/ (b0915dcf's path flag, already in place at this commit, discussed in "Where we are" above).

Exercise 1: run the compiler and the runtime at several verbosity levels

A small program, exercising both a string and an arithmetic effect:

cat > Greet.hs <<'EOF'
module Greet where
import Prelude
import IO

main = IO.do
  putStrLn "hello"
  print (6 * 7)
EOF
nix develop $MICROHS -c ./bin/uhs -ilib Greet

Compiler-side verbosity is a single flag (on or off). Without -v, bin/uhs prints nothing at all β€” it just writes out.comb. With -v, it traces every module import (including cache hits) and dumps every compiled definition's combinator form before writing the file:

nix develop $MICROHS -c ./bin/uhs -v -ilib Greet
importing "Greet"
importing "Prelude"
importing done "Prelude"
importing "IO"
importing cached "Prelude"
importing "List"
importing cached "Prelude"
importing done "List"
importing done "IO"
importing done "Greet"
Greet.main = IO.>> (IO.putStrLn (O 'h' (O 'e' (O 'l' (O 'l' (O 'o' K)))))) (IO.print (Prelude.* 6 7))
IO.ReadMode = B K (B K K)
...

(... elides the rest β€” every remaining Prelude/List/IO definition's combinator form, followed by the entire linked program on one final line. importing cached "Prelude" appears twice: IO and List both import Prelude, and the second import is served from Compile.hs's module cache rather than re-parsed β€” visible only because -v traces it.)

Runtime verbosity is graded. Level 0 is the program's own output and nothing else:

nix develop $MICROHS -c ./bin/eval out.comb > /tmp/greet-lvl0.txt
nix develop $MICROHS -c ./bin/eval -v out.comb > /tmp/greet-lvl1.txt
nix develop $MICROHS -c ./bin/eval -v -v out.comb > /tmp/greet-lvl2.txt
nix develop $MICROHS -c diff /tmp/greet-lvl0.txt /tmp/greet-lvl1.txt
2a3,7
> 
> main returns $I
> node size=16, heap size=100000
> 260 reductions, 1 GCs, max cells used 140
> 140 cells at start

Level 1 (-v) adds exactly the post-run diagnostic trailer ef082bbe gated: what main returned ($I, the identity combinator β€” IO.>>'s final result, discarded), node/heap size, and reduction/GC/cell counts. Level 2 (-v -v) adds one more thing, and it comes before the program's own output rather than after it:

nix develop $MICROHS -c diff /tmp/greet-lvl1.txt /tmp/greet-lvl2.txt
0a1
> (($IO.>> ((((($S' ($C' $IO.>>)) (($B (($B $Y) (($B ($B ($P ($IO.return $I)))) (($B ($C' $B)) ($B $IO.>>))))) $IO.putChar)) (($C $IO.putChar) '
)) $IO.stdout) (($O 'h) (($O 'e) (($O 'l) (($O 'l) (($O 'o) $K))))))) (($IO.serialize $IO.stdout) (($* 6) 7)))

That's c0fb9a91's pp(stdout, n) call: the entire loaded program graph, printed once, before evalio runs β€” the same $T/$K/$O textual form Try It exercises in earlier chapters read out of out.comb files, but here it's the in-memory graph right after loading and garbage collection, not the file on disk (the two happen to look alike because IO.serialize and pp share the same printer, per chapter 04's "What happened"). Three levels, three different questions answered: level 0, "what does the program do"; level 1, "how much work did that take"; level 2, "what does the compiled program actually look like before it runs."

Exercise 2: add one more test to the harness, in its own style

Follow 9b6e23c8+6ac9c791's pattern exactly: a module, a .ref file holding its real output, one more line in tests/Makefile.

cat > tests/ArithTest.hs <<'EOF'
-- Arithmetic test
module ArithTest where
import Prelude
import IO

main = IO.do
  print (2 + 3)
  print (10 - 4)
  print (6 * 7)
EOF

Run it once by hand to find out what it actually prints β€” the harness records real output, not predicted output:

cd tests
nix develop $MICROHS -c ../bin/uhs -i../lib ArithTest
nix develop $MICROHS -c ../bin/eval out.comb
5
6
42

Save that as the reference file:

nix develop $MICROHS -c ../bin/eval out.comb > ArithTest.ref
cat ArithTest.ref
5
6
42

Add one line to tests/Makefile, in the same style as Fac's:

	$(UHS) ArithTest && $(EVAL) out.comb > ArithTest.out && diff ArithTest.ref ArithTest.out

Run the whole harness from the top level, exactly as make test runs it for a reader who hasn't touched anything above:

cd ..
nix develop $MICROHS -c make test
ghc Main.hs -o bin/uhs
cd tests; make
../bin/uhs -i../lib IOTest && (echo q | ../bin/eval out.comb > IOTest.out) && diff IOTest.ref IOTest.out
../bin/uhs -i../lib StringTest && ../bin/eval out.comb > StringTest.out && diff StringTest.ref StringTest.out
../bin/uhs -i../lib Fac && ../bin/eval out.comb > Fac.out && diff Fac.ref Fac.out
../bin/uhs -i../lib ArithTest && ../bin/eval out.comb > ArithTest.out && diff ArithTest.ref ArithTest.out

The leading ghc Main.hs -o bin/uhs is real, and it's Exercise 1's doing, not Exercise 2's: the top-level Makefile's $(BIN)/uhs: *.hs rule matches every .hs file in the top-level directory, including Greet.hs, left there by Exercise 1. Greet.hs is newer than bin/uhs, so make reruns the link step β€” harmlessly (nothing about Main.hs or its dependencies actually changed, so the binary itself comes out identical) but visibly, on every subsequent make test too, since a relink that changes nothing doesn't update bin/uhs's own timestamp. Four tests, no diff output from any of them β€” silence is the harness saying every one of them matched its golden file, ArithTest included.

Exercise 3: classify every commit in this chapter's range

git log --stat 8c5fbb1a..8018eec8, read commit by commit, classified by what the diff actually contains rather than by the commit's own title β€” the fifteen commits from 6c59dd36 through 8018eec8. Feature/fix/cleanup/docs are one reasonable four-way split; a fifth bucket, "mixed," is used once below rather than forcing a commit that genuinely touches several of the other four into just one of them. Disagreement is invited β€” several of these could be argued either way.

CommitSubjectFiles touchedClass
6c59dd36UpdateMakefilecleanup
9b6e23c8Better names..gitignore, Main.hs, Makefile, tests/Fac.uhscleanup
73a427d7Rename files to .hs8 renames, Compile.hs, Makefile, tests/Makefilecleanup
6ac9c791Add some testing.tests/*.ref, tests/IOTest.hs, tests/Makefilefeature
110bcc94Fix minor issues..gitignore, Makefile, tests/Makefilefix
37e44acdAdd READMEREADME.mddocs
aae0eee2More text.README.mddocs
04f0840bMore text.Example.hs, README.mddocs
91a8ff34More textREADME.mddocs
0bfdcd18Fix some mistakes.README.mdfix
0b97ab3eAdd a flag to set the heap size.eval.cfeature
60343934More text.README.mddocs
3cdbb820Various small improvements.Compile.hs, Makefile, README.md, eval.c, lib/IO.hs, tests/Fac.hs, tests/Fac.ref, tests/Makefile, tests/Nfib.hsmixed
28d53dabClean upParserComb.hscleanup
8018eec8Add nulllib/List.hsfeature

9b6e23c8 is arguably itself mixed β€” most of its diff is renaming and gitignore cleanup, but it also introduces tests/Fac.uhs, genuinely new content that Habit 2's test harness later depends on; it's tabled as cleanup because that's the commit's own stated intent ("Better names.") and the larger share of its diff. 3cdbb820 is the one entry that resists a single label outright: a real bug fix (Compile.hs's module-path lookup, dots to slashes, for names it can't yet actually produce), a feature (tests/Nfib.hs, a new heavier benchmark, plus a make time target), a cleanup (lib/IO.hs's mapM moves from the fix (\ rec -> ...) idiom to plain recursive let, and tests/Fac.hs drops the nfib 35 line Nfib.hs now supersedes), and a docs addition (the README's ## Libraries paragraph) β€” four of the other four categories, in one commit (3cdbb820 --stat's own total: "9 files changed, 45 insertions(+), 18 deletions(-)").

Clean up:

cd tests && rm -f ArithTest.hs ArithTest.ref *.out *.tmp && cd ..
git worktree remove --force /tmp/mhs-37e44acd