Syllabus · A1
Enum carrying state and behaviour — a name, a value, or a seam
- Tier 1
- Modelling primitives
- 8 min read
- after J10
The idea
A name, a value, or a seam
A parking lot charges by vehicle type, so the first version takes the type as a String. park(plate, "TRUCK") works. So does park(plate, "TRUKC"), and the build is green:
a/ with Gate saying "TRUKC"
javac exit 0 nothing printed
Typed, the same character costs you a build instead of a truck:
| Gate.java:5: error: cannot find symbol
| lot.park(plate, VehicleType.TRUKC);
| symbol: variable TRUKC
That is the stringly-typed tag, and it routes here. The enum is the cheap part. The judgement is what goes on it, and one sentence settles that in ten seconds:
TRUCK's ______ is ______, and that depends on ______.
No first blank, so there is no per-constant fact: leave it bare, a name. corpus/parking-lot/contract/VehicleType.java is one line for that reason.
Third blank reads "nothing else", and you cannot name a second right answer: put it on the constant. A truck takes 2 spots, and Footprint.java holds the 2.
Third blank names anything, or a second right answer exists: it goes behind a seam, with the enum as its key. A truck's two hours cost 8000 under one tariff and 2000 under another, both measured in worked/, so TRUCK cannot hold either.
One enum, three homes, all three on disk in one corpus problem. contrast/ measures what the choice is worth, and reports the graded instrument going the other way: 6 lines against 7, then 44 against 64.
Worked walkthrough
NOTES — one enum, three treatments, and the sentence that picks between them
Run it first
.toolchain\jdk-21\bin\javac.exe -Xlint:all -d out *.java
.toolchain\jdk-21\bin\java.exe -cp out Main
javac prints nothing. Real output, verbatim:
--- 1. VehicleType is a name
constants : [MOTORBIKE, CAR, TRUCK]
methods it declares : []
methods on Footprint: [of(), spots()]
--- 2. the spot count is on the constant
MOTORBIKE -> 1 spot(s)
CAR -> 1 spot(s)
TRUCK -> 2 spot(s)
--- 3. the rate is behind PricingPolicy
TRUCK, 2h flat 8000 tiered 2000
TRUCK, 5h flat 20000 tiered 16000
CAR, 5h flat 10000 tiered 8000
--- 4. one mis-cased string, against StringlyLot
free before : 4
park accepted : yes
free after : 3 (a truck takes 2)
checkout threw : java.lang.IllegalArgumentException
message : no tariff configured for Truck
free after refusal : 4
--- 5. the same string, against Lot
free before : 4
park threw : java.lang.IllegalArgumentException
message : No enum constant VehicleType.Truck
free after : 4
spelled right, free: 2
fee for 2h : 8000
Read block 1 before anything else. VehicleType declares no methods at all, and Footprint declares two. Both are enums over the same three names, written by the same team, in the same problem. So this item is not "should this be an enum". It is "what belongs on it", and the corpus answered that question three different ways for one set of three names.
J10 covered how a constant carries a field, a constructor and a body. This file is about which of those the requirement is asking for.
The sentence, and the two checks that point opposite ways
The slogan going round is make your enums rich, not anemic. It has one direction. Follow it and you end up with a spots() and a rate() and a label() on the same constant. One of those is a fact. The other two are somebody's decision this quarter. Then the interviewer changes the tariff and you are editing an enum.
Replace it with a sentence you can finish in ten seconds. For one constant, out loud:
TRUCK's ______ is ______, and that depends on ______.
Three blanks. The third one decides everything, and the checks run in both directions.
| What you find | Where the fact goes | The bug on the other side |
|---|---|---|
| No first blank. There is no per-constant fact | Bare enum. It is a name | Inventing a field means inventing a fact |
| Third blank is "nothing else", and there is one right value | A final field on the constant | A side table drifts, silently |
| Third blank names anything at all | Behind a seam, with the enum as its key | A constant body cannot be swapped or tested alone |
The symmetric check is the second row against the third. Ask whether you can name a second right answer for the same constant. If you cannot, the value belongs on the constant, and keeping it in a side table is the drift J10's MINOR_VALUE map demonstrates. If you can, the constant must hold neither of them, because holding one makes the other unwritable.
Try both on a truck.
- A truck's spot count is 2, and that depends on nothing else. Name a second right answer. There is none, because a truck is the length it is. Row two.
- A truck's rate is 4000 per started hour, and that depends on the tariff in force. Name a second right answer. Block 3 above prints two of them, 8000 and 2000 for the same two-hour stay. Row three.
Same enum, same constant, two different homes, and the sentence separates them without any appeal to taste.
Where the sentence disagrees with itself, and what to do
Sometimes the third blank names exactly one other thing, and that thing is also a closed set. A vending machine's legal next state depends on the state and on the event, and both are enums. That is a table keyed by the pair, not a body on either constant, and it is item B6. corpus/vending-machine/reference/src/ builds it as EnumMap<MachineState, EnumMap<MachineEvent, MachineState>> from a list of (from, on, to) rows. The tell that a body is wrong there is in J10's notes: two of the three allows bodies come out byte-identical, because the rule is about the pair.
VehicleType.java — the row-one case
public enum VehicleType { MOTORBIKE, CAR, TRUCK }
One line, and the corpus's version is also one line. corpus/parking-lot/contract/VehicleType.java is a contract file, marked GIVEN. Do not edit., and it carries nothing. The invariant it holds is the only one it needs: the set of vehicle types is closed, and javac owns the list.
What that buys is block 5. Lot.park("KA01AB1234", "Truck") throws before a field changes, and the message is No enum constant VehicleType.Truck. Compare block 4, where the same string parks.
corpus/rate-limiter/contract/Algorithm.java is the same choice made on purpose and defended at length. Its javadoc: "This enum is a selector, not the algorithm. It names a behaviour; it does not implement one, and it is deliberately free of state and of switch-worthy logic."
Footprint.java — the row-two case
MOTORBIKE(1),
CAR(1),
TRUCK(2);
The number is an argument to the constant, so a fourth constant cannot be declared without one. That is the invariant, and it is the whole of row two. The bug it prevents is the one J10 measures on LooseCoinPurse: a Map<VehicleType, Integer> compiles fine when a new type is added, and answers null at the first lookup after the deploy.
The corpus file says it in a sentence worth stealing for a round:
A fact about vehicles, not a policy, so it lives on an enum next to the number it carries rather than in a table somewhere else.
public static Footprint of(VehicleType type) {
return switch (type) {
case MOTORBIKE -> MOTORBIKE;
case CAR -> CAR;
case TRUCK -> TRUCK;
};
}
No default, and that absence is the design argument of this lesson. A switch expression must cover every constant, so a fourth VehicleType stops this file compiling. Add VAN and javac says:
Footprint.java:33: error: the switch expression does not cover all possible input values
return switch (type) {
^
1 error
Reproduce it with node lessons/A1/contrast/measure.mjs, which patches a copy of contrast/b/ and prints that output. Two things the message does not do: it does not name VAN, and it does not point at the case labels. It gives you the file, the line and the selector.
A default branch buys the silence back. Every future constant is already covered by it, so the file compiles and a van occupies whatever the default says. So does a Map<VehicleType, Footprint> lookup. So does Footprint.values()[type.ordinal()], which additionally goes wrong the day the two declaration orders diverge.
Why a separate enum rather than a field on VehicleType. Only because contract/ is read-only, and the corpus file says so. Given the choice, the number goes on VehicleType itself and of() does not exist. Keeping the mapping exhaustive is what makes the second-best option safe.
PricingPolicy.java and its two implementations — the row-three case
long feeMinor(VehicleType type, Duration stay);
Two parameters, and only one of them is the enum. That signature is the fastest tell in the whole lesson. A method that needs a second input cannot be a method on the constant, because a constant body has access to the constant and nothing else. Anything you would have to pass in is evidence that the answer is not the constant's.
Block 3 prints the other half of the evidence. The same truck, the same two hours, 8000 under FlatHourlyPricing and 2000 under TieredPricing. Two right answers, so no field and no body on TRUCK can hold the rate.
public FlatHourlyPricing(Map<VehicleType, Long> perStartedHour) {
this.perStartedHour = new EnumMap<>(perStartedHour);
}
The copy is what stops a caller mutating the tariff after construction, and EnumMap is what fixes iteration order and rejects a null key on the spot. corpus/parking-lot/reference/src/FlatHourlyPricing.java holds the same table as a private static final Map instead. The reason this one takes it as an argument is testability. A tariff with a type missing is the failure the next method exists to catch, and a static table cannot be handed a missing entry from a test. faded/GapTest.java hands it one.
private long rateFor(VehicleType type) {
Long rate = perStartedHour.get(type);
if (rate == null) {
throw new IllegalArgumentException("no tariff configured for " + type);
}
return rate;
}
This guard is the price of row three, and the corpus pays it twice. The text is word for word what corpus/parking-lot/reference/src/FlatHourlyPricing.java writes, and corpus/parking-lot/curveballs/02-tiered-pricing/reference-patch/TieredPricing.java writes it again with a different name. Once the answer lives behind the seam, no compiler checks that the table mentions every constant. So the class checks at runtime and names the constant it could not price.
That is not an argument against the seam. It is the trade the seam makes, and when-not.md prices it. corpus/rate-limiter/reference/DECISION_LOG.md makes the same trade and the same admission for the algorithm registry:
And a missing registration throws, naming the constant — the alternative, falling back to "allow everything", would turn a one-line omission into a silently unlimited client.
Lot.java — where the string stops being a string
VehicleType type = VehicleType.valueOf(vehicleType);
int needed = Footprint.of(type).spots();
Line one is the boundary, and its position in the method is the invariant. Nothing in the lot has changed yet, so a refusal here leaves used, open and free() exactly as they were. Block 5 checks it: free after is 4, the same as free before.
Move valueOf two lines down, after the capacity check, and the lot still works. Move it into checkout and you have rebuilt StringlyLot.
private record Stay(VehicleType type, int spots) {}
The stored field is the enum, not the caller's string. Everything downstream of park now reads a value that was validated once. A Stay holding a String would need every reader to re-decide what a valid type is, and readers disagree. That disagreement is the bug lessons/C1/contrast measures under instrument 3.
StringlyLot.java — the version the failure tag is named after
stringly-typed routes to this lesson, and it routes to the faded stage. Check it yourself in server/lib/lessons.mjs:
'stringly-typed': { item: 'A1', stage: 'faded' },
This class is not a straw man. The two maps are typed and immutable, the tariff has an explicit guard, and it passes every test written against the three types it knows. Two lines decide how it fails.
int needed = SPOTS.getOrDefault(vehicleType, 1);
An unknown type is assumed to need one spot. Nothing about that is unreasonable. Most vehicles do need one spot, and refusing a car because the caller sent lowercase would be an outage on a Friday night. It is also the line that lets a truck through the gate as a motorbike. Block 4 is what that looks like: free after reads 3 where the truck is filling 2 spots.
Long rate = PER_STARTED_HOUR.get(parked.type());
if (rate == null) {
throw new IllegalArgumentException("no tariff configured for " + parked.type());
}
The same guard as the typed design, running at the wrong time. Read the last three lines of block 4 in order:
checkout threw : java.lang.IllegalArgumentException
message : no tariff configured for Truck
free after refusal : 4
The truck is inside. The stay has been removed from open, and used has given back the one spot it took. So the software now believes all four spots are free while a truck occupies two of them, and nobody was billed. One mis-cased character produced a capacity error, a revenue error and an exception, in that order, and the exception is the only one anybody sees.
contrast/Probe.java counts the distance: four calls in this design against one in the typed one.
The two corpus choices this rule has to reproduce
A rule that only fires one way is not a rule. corpus/vending-machine and corpus/rate-limiter chose opposite treatments for their central enum, both deliberately, and both wrote down why. The sentence has to yield both.
Vending machine, row two. A quarter's value is 25 cents, and that depends on nothing else. corpus/vending-machine/contract/Coin.java is QUARTER(25), and its javadoc says the enum is the accepted set. Measured consequence, from corpus/vending-machine/curveballs/01-a-new-coin/budget.json: reference_diff: 0. A new denomination costs no lines at all. The patch note lists the three places that could have known how many coins there are and do not.
Rate limiter, row three. FIXED_WINDOW's counting behaviour is a window counter, and that depends on the rule's limit and window, the key's history, and the clock. Three things besides the constant, so the answer is not the constant's. corpus/rate-limiter/problem.json drops A1 from its syllabus list for exactly that reason:
Algorithmis a contract enum with two constants and deliberately no state and no behaviour on it [...] Tagging A1 here would be tagging the exact opposite of what the problem teaches.
The clause the ellipsis covers is the reason: the behaviour lives somewhere else, and adding a constant does not mean growing a switch.
Measured consequence, from corpus/rate-limiter/reference/DECISION_LOG.md. A sliding-window log is a third implementation of the same three methods, and it measured reference_diff = 1, "the single implementations.put(...) line whose whole job is to be the only line."
Two problems, one rule, both answers. Neither was arranged after the fact: both notes were written by the corpus author before this lesson existed, and both are on disk.
And the same rule inside one problem. corpus/vending-machine also keeps its pricing behind PricingPolicy, and curveballs/02-happy-hour measures that at reference_diff: 2, one added line and one removed. The patch names what did not happen: "VendingMachine was not opened. That is the measurement."
What an interviewer is measuring
Say the third blank out loud when you write the code. "The spot count goes on the constant because a truck is the length it is. The rate goes behind an interface because the tariff changed last month." That is two design sentences and it takes eight seconds.
"I used an enum" is not a design sentence. Neither is "I made the enum rich".
When not to
Three ways to get this wrong, and one of them looks like the lesson
The sentence in idea.md has three outcomes, so it has three ways to land on the wrong one. Two of them are the ordinary failures. The third is the interesting one, because the fast pass says yes and the answer is still no.
1 · The constant body that wanted to be a policy
Here is worked/src after somebody has read "put behaviour on the enum" and stopped there. It compiles: dropped next to worked/src/, minus Main.java, and built with javac 21 -Xlint:all, it prints nothing.
import java.time.Duration;
/** The tariff, as a body per constant. */
public enum VehicleTariff {
MOTORBIKE {
@Override
public long ratePerStartedHour() {
return 1000L;
}
},
CAR {
@Override
public long ratePerStartedHour() {
return 2000L;
}
},
TRUCK {
@Override
public long ratePerStartedHour() {
return 4000L;
}
};
public abstract long ratePerStartedHour();
public long feeMinor(Duration stay) {
return ratePerStartedHour() * PricingPolicy.startedHours(stay);
}
public static VehicleTariff of(VehicleType type) {
return switch (type) {
case MOTORBIKE -> MOTORBIKE;
case CAR -> CAR;
case TRUCK -> TRUCK;
};
}
}
It works. It is shorter than PricingPolicy plus FlatHourlyPricing. It is also abstract, so a fourth constant cannot be declared without a rate, which is a real guarantee and better than the map behind the seam. Somebody will defend it on exactly that ground in a round.
Four things it costs.
The second tariff is not expressible. TieredPricing in worked/src prices a truck at 0, 2000 or 6000 for the same hour, depending on which hour it is. A constant body sees the constant and its own arguments. To put tiering in here, every one of the three bodies has to learn the tier rule, and the rule is the same in all three. So the thing varying is not the constant.
The failing test in faded/GapTest.java cannot be written at all. Gap 4 hands FlatHourlyPricing a table with TRUCK missing and asserts it refuses by name. Against VehicleTariff there is nothing to hand anything to. new VehicleTariff(4000L) is refused by the compiler, Coin-style, and there is no constructor, no setter and no seam. A constant body cannot be stubbed, injected or given a fixture, and that is not a Java limitation to work around. It is what a constant is.
A new tariff means editing this file instead of adding one. Measured, from corpus/parking-lot/curveballs/02-tiered-pricing/budget.json, the reference absorbs the tiered tariff at reference_diff: 2:
The reference adds TieredPricing.java (a new file, free) and rewrites the one line of ParkingLot's convenience constructor that names the default tariff: 1 added + 1 removed = 2.
Two lines, one of which is construction. The VehicleTariff version edits three bodies and cannot hold both tariffs at once, so switching back for a holiday weekend is another edit.
The duplication is the tell, and it is visible before any of the above. Three bodies that differ only in a literal are not three behaviours. lessons/J10/worked/NOTES.md states the general form: when two constant bodies come out the same, the thing varying is not the constant.
What the grader sees: D3's closed defect vocabulary in server/lib/rubric.mjs has no tag for this shape specifically. Its nearest is if-chain-instead-of-policy, since a body per constant is the conditional written out longhand, and that tag routes to B1's faded stage rather than here.
2 · The enum for a set that is not yours to close
Currencies. Country codes. Payment providers. HTTP status codes. These fail the first check for a different reason than VehicleType passes it: the set is not closed at build time. ISO 4217 adds and retires codes on somebody else's schedule, so the compiler cannot be the list, because the list is not yours.
The corpus faces this and refuses the enum. corpus/splitwise/problem.json puts "currency conversion" in out_of_scope, and "Is this one currency throughout?" is one of its good_questions. Then curveballs/03-multi-currency-expenses takes it back, and the reference absorbs it with a rate supplied by the caller and a new collaborator. From its budget.json:
15 lines, one file touched. CurrencyConversion.java is a new file (free) that converts an already-resolved list of Splits [...]
There is no Currency enum anywhere in it. The requirement's own words are "you'll be given a conversion rate at the time". A value that arrives at run time cannot be a constant that was compiled last week.
What it costs to close it anyway, measured
This is the requirement in contrast/ that goes against the typed design. From node lessons/A1/contrast/measure.mjs:
partner-registered categories a -> a-open diffLines 44 touched 2 new 0 deleted 0
[Lot.java +6/-2, Rates.java +23/-13]
partner-registered categories b -> b-open diffLines 64 touched 4 new 2 deleted 2
[Gate.java +1/-1, Lot.java +13/-12, FlatHourlyPricing.java +0/-28, PricingPolicy.java +0/-9]
Forty-four lines across two files untyped, against sixty-four across four typed. Two of the four are deletions: PricingPolicy and FlatHourlyPricing stop existing, because a rate that arrives at run time cannot be keyed by a constant that does not. Gate.java is the smallest and the worst of them. Its call site goes from VehicleType.TRUCK, which the compiler checks, to "TRUCK", which it does not.
VehicleType and Footprint survive in b-open/ as seed data for the three built-in categories. That is the honest shape of this mistake in a real codebase. The enum stays on disk, stops being load-bearing, and every new reader has to work out which of the two paths is the real one.
The part that changes what the lesson is about
Both open-set designs refuse the mis-cased string at the first public call:
---------- a-open ----------
detected at public call 1
message unknown vehicle category: Truck
blamed Rates.spots(Rates.java:28)
a-open/ has no enum anywhere, and on the instrument that decides this lesson it is as safe as b/. Registration is what made it safe. The enum keyword is the cheapest way to close a set the compiler can see, and it is not the only way to close one. For an open set the answer is a validated value type and a registry, which is A2 and A5 rather than this item.
3 · The one where the fast pass says yes and the answer is no
corpus/cost-explorer/contract/Plan.java:
public enum Plan { BASIC, STANDARD, PREMIUM }
Three constants, no field, no method. Now run the fast pass on it. BASIC's monthly price is 999, and that depends on ______. The problem's clarified prompt says the three plans are "each fixed and the same for every account". So the third blank comes back empty, which points at a field on the constant, which is the wrong answer.
The corpus states the decision in the file itself:
Pricing is deliberately kept off this enum so that "what a plan costs" has exactly one home instead of leaking into every place a
Planis used.
The guard is what catches it. Name a second right answer for BASIC's price. A promotion. A grandfathered rate for accounts opened before the increase. Regional pricing. Every one of those is ordinary, and a price is a decision somebody made and will remake.
Compare a quarter. Name a second right answer for what QUARTER is worth. There is none, because 25 cents is what the coin is, and corpus/vending-machine/contract/Coin.java writes QUARTER(25) for that reason.
So the two checks are not interchangeable, and the order matters. The third blank is the fast pass. The second-right-answer question is the one that decides, and Plan is the case that proves you have to ask it.
The axis this lesson deliberately did not move
Footprint's spot count stays on the constant, and Footprint.of stays an exhaustive switch with no default. Naming that is the cheapest way to show the sentence was applied rather than recited.
The reason is instrument 3, row one:
b/ + VAN added to VehicleType only
javac exit 1
| Footprint.java:20: error: the switch expression does not cover all possible input values
A SpotPolicy interface would replace that compile error with a runtime guard, which is the trade FlatHourlyPricing already makes and already pays for. Two guards where one compile error would do.
The corpus agrees, and it agrees in an unobvious way. corpus/parking-lot/curveballs/01-motorbikes-share-a-spot/ sounds like a footprint requirement, and the reference absorbs it somewhere else entirely. From its budget.json:
The reference adds SharedMotorbikeAllocator.java (a new file, free) and rewrites the one line of ParkingLot's convenience constructor that names the default allocator: 1 added + 1 removed = 2.
Two lines, through SpotAllocator. So the one requirement that looked like it wanted the footprint seamed landed on the seam that already existed. reference/src has a SpotAllocator and a PricingPolicy and no FootprintPolicy, and that is a minimal seam set rather than an oversight.
The standing cost of the typed design, before any requirement arrives
a 3 file(s) 70 normalised lines
b 6 file(s) 110 normalised lines
Forty lines and three files, for behaviour that is identical and a base suite that is green either way, at 5 of 5. A twelve-minute clock feels three files. STANDARD v1.0 caps D2 at 0 when no main exists, because "interviewers run it first", and D2 is weighted 25% against D3's 20%.
Worth paying for a vehicle type. Not worth paying for a set of two names nothing branches on.
The threshold, from all three sides
Bare enum when the sentence has no first blank. A closed set of names, and the compiler owns the list. VehicleType, Algorithm, EntryKind, Plan.
A field on the constant when the third blank is "nothing else" and you cannot name a second right answer. Coin(25), Footprint(2). Leaving the value in a side table is the drift J10 measures.
Behind a seam when the third blank names anything, or when a second right answer exists. Then the enum is the key and not the author, and B1's threshold takes over: name the second implementation and write the one signature both satisfy.
Not an enum at all when the set is decided after the build. Close it with a validated value type and a registry, and keep the refusal at the first public call.
The contrast pair
Two designs, two requirements, four instruments
a/ keeps the vehicle type as a String. Two typed immutable tables in Rates.java, a lot that counts spots, and a gate that admits a truck. b/ is the same lot with VehicleType, Footprint and PricingPolicy doing the same work.
Both compile under -Xlint:all with nothing printed. Both pass BaseTest.java, 5 of 5. This is a comparison of two designs that work.
And a/ is not a straw man. corpus/vending-machine/curveballs/01-a-new-coin/reference-patch/PATCH.md lists the plausible wrong answers for this exact item. Its verdict on them:
It looks like the easiest of the three and it is the most discriminating, because the plausible wrong answers are all cheap-looking.
Look at a/'s two lines that decide how it fails.
return SPOTS.getOrDefault(vehicleType, 1);
An unknown type needs one spot. Most vehicles do, and refusing a car because the caller sent lowercase would be an outage.
Long rate = PER_STARTED_HOUR.get(vehicleType);
if (rate == null) {
throw new IllegalArgumentException("no tariff configured for " + vehicleType);
}
An unknown type has no rate, and guessing one would be billing fraud. That is the same guard corpus/parking-lot/reference/src/FlatHourlyPricing.java writes. Neither line is careless. Together they are why one mis-cased character becomes a capacity error rather than a refusal.
Why this lesson needs four instruments
lessons/C3/when-not.md settled the rule for the whole set: pick the instrument that can see the benefit you are claiming, and say which one you used. The claim here is that a wrong value is refused at the door instead of stored, and that a new constant becomes a list of files rather than a hunt. A line count can see neither.
So: four instruments, and the graded one goes against b/ on both requirements.
Change one, in the interviewer's words
We are adding vans. A van takes two spots, like a truck, and bills at 3000 a started hour, between a car and a truck. Nothing about the three existing types changes.
VanTest.java asserts all of it. Both a-van/ and b-van/ pass 3 of 3, base suite included.
Change two, in the interviewer's words
Partner operators onboard their own vehicle categories through an admin API. A registration carries a name, a spot count and an hourly rate. We will not know the names at build time.
OpenTest.java asserts that, including that a registration nobody could honour is refused when it arrives. Both a-open/ and b-open/ pass 4 of 4.
This is the requirement that goes the wrong way for the closed set, and it is here on purpose.
The numbers
node lessons/A1/contrast/measure.mjs
Real output, from exactly these directories:
=== both designs work: BaseTest against every tree ===
a BaseTest 5/5
b BaseTest 5/5
a-van BaseTest 5/5 VanTest 3/3
b-van BaseTest 5/5 VanTest 3/3
a-open BaseTest 5/5 OpenTest 4/4
b-open BaseTest 5/5 OpenTest 4/4
=== instrument 1 — measureChange(), the function that scores D4 ===
a fourth vehicle type, VAN a -> a-van diffLines 6 touched 1 new 0 deleted 0
[Rates.java +4/-2]
a fourth vehicle type, VAN b -> b-van diffLines 7 touched 3 new 0 deleted 0
[FlatHourlyPricing.java +1/-0, Footprint.java +3/-1, VehicleType.java +1/-1]
partner-registered categories a -> a-open diffLines 44 touched 2 new 0 deleted 0
[Lot.java +6/-2, Rates.java +23/-13]
partner-registered categories b -> b-open diffLines 64 touched 4 new 2 deleted 2
[Gate.java +1/-1, Lot.java +13/-12, FlatHourlyPricing.java +0/-28, PricingPolicy.java +0/-9]
=== what each design costs before any requirement arrives ===
a 3 file(s) 70 normalised lines
b 6 file(s) 110 normalised lines
a-van 3 file(s) 72 normalised lines
b-van 6 file(s) 113 normalised lines
a-open 3 file(s) 84 normalised lines
b-open 6 file(s) 113 normalised lines
Instrument 1, and what it settles
Nothing, in favour of the typed design. Read the four rows as a set.
The van costs 6 lines in one file untyped against 7 lines across three files typed. STANDARD v1.0's D4 level 3 is "absorbed by adding files only — zero lines changed in pre-existing files". Neither design reaches it, and on files touched the typed one is three times further away.
The partner categories cost 44 lines across two files untyped against 64 across four typed, two of which are deletions. PricingPolicy and FlatHourlyPricing stop existing, because a rate that arrives at run time cannot be keyed by a constant that does not.
Before either requirement, b/ is 110 normalised lines across 6 files against a/'s 70 across 3. Forty lines and three files, paid up front, for behaviour that is identical and a base suite that is green either way.
So the graded instrument says the String design is cheaper, twice. That is the honest result and it was not what this pair was built expecting. What it means is that measureChange is answering a different question from the one the item is about.
Instrument 2, which is the one that decides it
This is A5's instrument: whether a wrong value is refused or stored, and how far it travels before anything notices. Probe.java sends the same string, "Truck", into a four-spot lot and counts the public calls it survives.
---------- a ----------
scenario 1 park("Truck"), then checkout
call 1 park accepted, usedSpots 1 (a truck fills 2)
detected at public call 2
threw java.lang.IllegalArgumentException
message no tariff configured for Truck
blamed Rates.perStartedHour(Rates.java:24)
usedSpots after 0 free 4
scenario 2 park("Truck"), then the occupancy report
call 2 usedSpots 1 free 3
detected never, and no exception is reachable
---------- b ----------
scenario 1 park("Truck"), then checkout
detected at public call 1
threw java.lang.IllegalArgumentException
message No enum constant VehicleType.Truck
blamed java.base/java.lang.Enum.valueOf(Enum.java:293)
usedSpots after 0 free 4
Read scenario 1 in a/ line by line. park returns normally. The lot now believes one spot is taken while a truck fills two. The exception arrives one public call later, at the till, and it blames Rates.perStartedHour for a decision park made. Then look at the last line: usedSpots is back to 0 and free is 4, because checkout released the stay before the guard fired. A truck is standing in the lot and the software says the lot is empty.
Scenario 2 is the one to remember. The same mis-cased string, read by a nightly occupancy report that never touches the tariff, and nothing throws at all, ever. usedSpots 1, free 3 is the permanent answer. The exception in scenario 1 was not the system catching the error. It was the system running out of places to hide it.
In b/ the same string is refused at public call 1, by Enum.valueOf, before a field moves. There is no scenario 2, because there is no stored value to report on.
The -open trees say something about why that works
Both open-set designs also refuse "Truck" at call 1:
---------- a-open ----------
detected at public call 1
message unknown vehicle category: Truck
blamed Rates.spots(Rates.java:28)
---------- b-open ----------
detected at public call 1
message unknown vehicle category: Truck
blamed Categories.get(Categories.java:27)
a-open/ has no enum anywhere and it is as safe as b/ on this measurement. The safety came from the set being closed and validated, not from the enum keyword. Registration is what closed it.
That is the sentence to take into a round when somebody asks why not an enum for currencies. An enum is the cheapest way to close a set the compiler can see. It is not the only way to close one, and it is the wrong way when the set is decided after the build.
Instrument 3, the compiler worklist, and its exact boundary
The design argument for a closed set is that adding a member turns into a to-do list somebody else wrote for you. Here is that list, and here is where it stops.
b/ + VAN added to VehicleType only
javac exit 1
| Footprint.java:20: error: the switch expression does not cover all possible input values
| return switch (type) {
| ^
| 1 error
b/ + VehicleType and Footprint updated, tariff table forgotten
javac exit 0 nothing printed
VanTest 2/3
| FAIL a van is billed at 3000 a started hour, between a car and a truck
| no tariff configured for VAN
a/ + VAN added to the tariff table, spot count forgotten
javac exit 0 nothing printed
VanTest 2/3
| FAIL a van takes two spots
| expected: <2> but was: <1>
a/ + nothing done at all
javac exit 0 nothing printed
VanTest 1/3
| FAIL a van is billed at 3000 a started hour, between a car and a truck
| no tariff configured for VAN
| FAIL a van takes two spots
| expected: <2> but was: <1>
Row one is the payoff. One word added to an enum, and the build hands back the next thing to do. Footprint.of has no default, so a switch expression that does not cover VAN is not a program. Two things the message does not do: it does not name VAN, and it does not point at the case labels.
Row two is the boundary, and it is the more useful half. The exhaustive switch is checked and the Map<VehicleType, Long> behind PricingPolicy is not, so a forgotten tariff entry compiles clean and fails at the till. The worklist covers what the compiler can see, and a map is not that. This is why FlatHourlyPricing carries a runtime guard at all, and why the corpus writes the same guard twice, in reference/src/FlatHourlyPricing.java and again in curveballs/02-tiered-pricing/reference-patch/TieredPricing.java.
Row three is row two's mirror in the untyped design, and the difference is the failure message. The typed design's forgotten entry names the constant: no tariff configured for VAN. The untyped design's forgotten entry says expected: <2> but was: <1> and throws nothing. The only reason anybody found out is that a test existed.
Row four is what "nothing done at all" looks like. Two of three van assertions fail and the third passes, so the design that needs no declaration also offers no place to start.
Instrument 4, the typo, in both directions
One character changed at a call site, in Gate.java, which admits a truck.
a/ with Gate saying "TRUKC"
javac exit 0 nothing printed
BaseTest 4/5
| FAIL a car takes one spot and a truck takes two
| expected: <3> but was: <2>
b/ with Gate saying VehicleType.TRUKC
javac exit 1
| Gate.java:5: error: cannot find symbol
| lot.park(plate, VehicleType.TRUKC);
| ^
| symbol: variable TRUKC
| location: class VehicleType
| 1 error
Same typo. On the left it is a green build, a truck parked in one spot, and a test that happened to count spots. On the right it is a build failure with the file, the line, the caret under the misspelling, and the class that does not have it.
This is what stringly-typed costs, and it is why that tag routes here. Check the route yourself in server/lib/lessons.mjs:
'stringly-typed': { item: 'A1', stage: 'faded' },
What this pair does not show
Two requirements against the corpus's three per problem, six public methods, and 110 normalised lines against a real reference. The full-scale version of change one is corpus/vending-machine/curveballs/01-a-new-coin/budget.json, where a new denomination measures reference_diff: 0, better than anything here, because the reference derives its denominations from Coin.values() rather than listing them.
Change two has no full-scale version anywhere in the corpus, and that absence is worth naming. No corpus problem asks for a closed set to be opened. So the 44 against 64 is the least-tested number in this lesson, and the shape of it matters more than the size.
Worked source
The 8 files of the worked design
Every file below is the one the app opens, verbatim. This is the part worth reading slowly: the prose above argues for a shape, and these are the lines that have it.
worked/src/FlatHourlyPricing.java47 linesworked/src/Footprint.java40 linesworked/src/Lot.java56 linesworked/src/PricingPolicy.java27 linesworked/src/StringlyLot.java76 linesworked/src/TieredPricing.java50 linesworked/src/VehicleType.java13 linesworked/src/Main.java102 lines
worked/src/FlatHourlyPricing.java47 lines
import java.time.Duration;
import java.util.EnumMap;
import java.util.Map;
/**
* Today's tariff: a flat rate per started hour, by vehicle type.
*
* <p>{@code corpus/parking-lot/reference/src/FlatHourlyPricing.java} holds the same table in a
* {@code private static final Map}. This one takes it as a constructor argument, for one reason:
* a tariff with a type missing is the failure this class exists to catch, and a static table cannot
* be handed a missing entry from a test. See {@code faded/GapTest.java}, which does exactly that.
*/
public final class FlatHourlyPricing implements PricingPolicy {
/** Standard rates in minor units per started hour, matching the corpus reference. */
public static Map<VehicleType, Long> standardTable() {
Map<VehicleType, Long> table = new EnumMap<>(VehicleType.class);
table.put(VehicleType.MOTORBIKE, 1000L);
table.put(VehicleType.CAR, 2000L);
table.put(VehicleType.TRUCK, 4000L);
return table;
}
private final Map<VehicleType, Long> perStartedHour;
public FlatHourlyPricing(Map<VehicleType, Long> perStartedHour) {
this.perStartedHour = new EnumMap<>(perStartedHour);
}
@Override
public long feeMinor(VehicleType type, Duration stay) {
return rateFor(type) * PricingPolicy.startedHours(stay);
}
/**
* The guard the corpus reference also writes, word for word. It is the price of putting the
* answer behind the seam: no compiler checks that this table mentions every constant, so the
* class has to check at runtime and name the constant it could not price.
*/
private long rateFor(VehicleType type) {
Long rate = perStartedHour.get(type);
if (rate == null) {
throw new IllegalArgumentException("no tariff configured for " + type);
}
return rate;
}
}
worked/src/Footprint.java40 lines
/**
* How many standard spots a kind of vehicle physically occupies.
*
* <p>Copied from {@code corpus/parking-lot/reference/src/Footprint.java}, whose own comment gives
* the reason in one line: <i>"A fact about vehicles, not a policy, so it lives on an enum next to
* the number it carries rather than in a table somewhere else."</i>
*
* <p>Run the sentence on it. A truck's spot count is 2, and that depends on nothing else. No second
* right answer exists, because a truck is the length it is. So the 2 goes on the constant.
*/
public enum Footprint {
MOTORBIKE(1),
CAR(1),
TRUCK(2);
private final int spots;
Footprint(int spots) {
this.spots = spots;
}
public int spots() {
return spots;
}
/**
* Exhaustive on purpose. There is no {@code default}, so a fourth {@link VehicleType} stops this
* file compiling, and the error is the first line of the worklist for the new constant.
*
* <p>{@code contrast/measure.mjs} adds {@code VAN} and prints what {@code javac} says.
*/
public static Footprint of(VehicleType type) {
return switch (type) {
case MOTORBIKE -> MOTORBIKE;
case CAR -> CAR;
case TRUCK -> TRUCK;
};
}
}
worked/src/Lot.java56 lines
import java.time.Duration;
import java.util.HashMap;
import java.util.Map;
/**
* A parking lot that counts spots and bills stays, with {@link VehicleType} as its only vocabulary
* for what kind of vehicle a stay is for.
*
* <p>The API takes a {@code String} on the way in, because that is where vehicle types come from:
* an HTTP body, a CSV, a barcode scanner. The whole point is what happens to that string on line
* one of {@link #park}.
*/
public final class Lot {
private final int capacity;
private final PricingPolicy pricing;
private final Map<String, Stay> open = new HashMap<>();
private int used;
public Lot(int capacity, PricingPolicy pricing) {
this.capacity = capacity;
this.pricing = pricing;
}
/**
* Parses the caller's string into the closed set once, at the boundary, and refuses anything
* that is not in it before a single field changes.
*
* @param vehicleType a constant name of {@link VehicleType}, case-sensitive
* @throws IllegalArgumentException if it is not one
*/
public void park(String plate, String vehicleType) {
VehicleType type = VehicleType.valueOf(vehicleType);
int needed = Footprint.of(type).spots();
if (used + needed > capacity) {
throw new IllegalStateException("lot full: " + free() + " free, " + needed + " needed");
}
open.put(plate, new Stay(type, needed));
used += needed;
}
public long checkout(String plate, Duration stay) {
Stay parked = open.remove(plate);
if (parked == null) {
throw new IllegalArgumentException("not parked: " + plate);
}
used -= parked.spots();
return pricing.feeMinor(parked.type(), stay);
}
public int free() {
return capacity - used;
}
private record Stay(VehicleType type, int spots) {}
}
worked/src/PricingPolicy.java27 lines
import java.time.Duration;
/**
* Decides how much a stay costs. Taken from
* {@code corpus/parking-lot/reference/src/PricingPolicy.java}.
*
* <p>The sentence comes out differently here. A truck's rate is 4000 minor units per started hour,
* and that depends on the tariff in force, the day of the week, and whether the operator is running
* a promotion. The third blank has three things in it, none of them the constant. So the answer is
* not the constant's to give, and {@link VehicleType} stays a key rather than becoming an author.
*
* <p>The signature is the tell. Two parameters, and only one of them is the enum.
*/
public interface PricingPolicy {
/**
* @param stay exact elapsed time in the lot, never negative
* @return the fee in minor units
*/
long feeMinor(VehicleType type, Duration stay);
/** Any part of an hour is a whole hour, and a zero-length stay is one. */
static long startedHours(Duration stay) {
long seconds = Math.max(0L, stay.toSeconds());
return Math.max(1L, (seconds + 3599L) / 3600L);
}
}
worked/src/StringlyLot.java76 lines
import java.time.Duration;
import java.util.HashMap;
import java.util.Map;
/**
* The same lot with the vehicle type left as a {@code String}. This is what the
* {@code stringly-typed} defect tag names, and it is not a straw man: two typed immutable maps, an
* explicit guard on the tariff, and it passes every test written against the three types it knows.
*
* <p>Two decisions in here are the ones a competent engineer makes under a twelve-minute clock, and
* they are the two that decide how the failure behaves.
*/
public final class StringlyLot {
private static final Map<String, Integer> SPOTS = Map.of(
"MOTORBIKE", 1,
"CAR", 1,
"TRUCK", 2);
private static final Map<String, Long> PER_STARTED_HOUR = Map.of(
"MOTORBIKE", 1000L,
"CAR", 2000L,
"TRUCK", 4000L);
private final int capacity;
private final Map<String, Stay> open = new HashMap<>();
private int used;
public StringlyLot(int capacity) {
this.capacity = capacity;
}
/**
* Decision one: an unknown type is assumed to need one spot. Nothing here is unreasonable —
* most vehicles do need one spot, and refusing a car because the caller sent lowercase would be
* an outage. It is also the line that lets a truck through the gate as a motorbike.
*/
public void park(String plate, String vehicleType) {
int needed = SPOTS.getOrDefault(vehicleType, 1);
if (used + needed > capacity) {
throw new IllegalStateException("lot full: " + free() + " free, " + needed + " needed");
}
open.put(plate, new Stay(vehicleType, needed));
used += needed;
}
/**
* Decision two: an unknown type has no rate, and guessing one would be billing fraud, so this
* one throws. It is the same guard {@code corpus/parking-lot/reference/src/FlatHourlyPricing}
* writes. The difference is when it runs: the vehicle is already inside and the spot count is
* already wrong by the time anybody asks for money.
*/
public long checkout(String plate, Duration stay) {
Stay parked = open.remove(plate);
if (parked == null) {
throw new IllegalArgumentException("not parked: " + plate);
}
used -= parked.spots();
Long rate = PER_STARTED_HOUR.get(parked.type());
if (rate == null) {
throw new IllegalArgumentException("no tariff configured for " + parked.type());
}
return rate * startedHours(stay);
}
public int free() {
return capacity - used;
}
private static long startedHours(Duration stay) {
long seconds = Math.max(0L, stay.toSeconds());
return Math.max(1L, (seconds + 3599L) / 3600L);
}
private record Stay(String type, int spots) {}
}
worked/src/TieredPricing.java50 lines
import java.time.Duration;
import java.util.Map;
/**
* The second implementation, copied from
* {@code corpus/parking-lot/curveballs/02-tiered-pricing/reference-patch/TieredPricing.java}: the
* first started hour free, the next two at the standard rate, everything from the fourth onward at
* the long-stay rate.
*
* <p>Two things about it are the reason {@link PricingPolicy} is an interface rather than a wider
* table. The computation is a loop over hours, not a lookup, so no map keyed by {@link VehicleType}
* could hold it. And it prices a truck at 0, 2000 or 6000 for the same hour depending on which hour
* it is, so "TRUCK's rate" has three right answers at once. A constant cannot hold three.
*
* <p>Note the second {@code rate} guard, identical in intent to {@link FlatHourlyPricing}'s. That
* repetition is measured in {@code when-not.md}.
*/
public final class TieredPricing implements PricingPolicy {
private static final long FREE_HOURS = 1;
private static final long STANDARD_UNTIL_HOUR = 3;
private static final Map<VehicleType, Long> STANDARD = Map.of(
VehicleType.MOTORBIKE, 500L,
VehicleType.CAR, 1000L,
VehicleType.TRUCK, 2000L);
private static final Map<VehicleType, Long> LONG_STAY = Map.of(
VehicleType.MOTORBIKE, 1500L,
VehicleType.CAR, 3000L,
VehicleType.TRUCK, 6000L);
@Override
public long feeMinor(VehicleType type, Duration stay) {
long fee = 0;
long hours = PricingPolicy.startedHours(stay);
for (long hour = FREE_HOURS + 1; hour <= hours; hour++) {
fee += rate(hour <= STANDARD_UNTIL_HOUR ? STANDARD : LONG_STAY, type);
}
return fee;
}
private long rate(Map<VehicleType, Long> tier, VehicleType type) {
Long rate = tier.get(type);
if (rate == null) {
throw new IllegalArgumentException("no tariff configured for " + type);
}
return rate;
}
}
worked/src/VehicleType.java13 lines
/**
* What kind of vehicle a stay is for. Nothing else.
*
* <p>This is byte-for-byte the shape of {@code corpus/parking-lot/contract/VehicleType.java}, which
* is one line long. There is no field on it and no method on it, and that is the decision — not an
* omission somebody will get round to. The three-blank sentence in {@code idea.md} finds nothing to
* put in the first blank: there is no fact that every vehicle type has and that the type alone
* settles. How much room it takes is a fact about the lot's geometry. What it costs is a decision
* the operator made this morning.
*
* <p>So the enum's whole job is to be a closed set of names that the compiler polices.
*/
public enum VehicleType { MOTORBIKE, CAR, TRUCK }
worked/src/Main.java102 lines
import java.lang.reflect.Method;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
/**
* Prints the three treatments of one enum, then triggers the same typo twice: once against the
* {@code String} lot and once against the typed one. Every number in {@code NOTES.md} comes from
* here.
*/
public final class Main {
private static final Duration TWO_HOURS = Duration.ofHours(2);
private static final Duration FIVE_HOURS = Duration.ofHours(5);
public static void main(String[] args) {
one();
two();
three();
four();
five();
}
/** 1. A name, and nothing else on it. */
private static void one() {
System.out.println("--- 1. VehicleType is a name");
System.out.println(" constants : " + Arrays.toString(VehicleType.values()));
System.out.println(" methods it declares : " + declaredMethods(VehicleType.class));
System.out.println(" methods on Footprint: " + declaredMethods(Footprint.class));
}
/** 2. A value that must not drift, so it sits on the constant. */
private static void two() {
System.out.println("--- 2. the spot count is on the constant");
for (VehicleType type : VehicleType.values()) {
System.out.println(" " + type + " -> " + Footprint.of(type).spots() + " spot(s)");
}
}
/** 3. A decision that has more than one right answer, so it sits behind the seam. */
private static void three() {
System.out.println("--- 3. the rate is behind PricingPolicy");
PricingPolicy flat = new FlatHourlyPricing(FlatHourlyPricing.standardTable());
PricingPolicy tiered = new TieredPricing();
System.out.println(" TRUCK, 2h flat " + flat.feeMinor(VehicleType.TRUCK, TWO_HOURS)
+ " tiered " + tiered.feeMinor(VehicleType.TRUCK, TWO_HOURS));
System.out.println(" TRUCK, 5h flat " + flat.feeMinor(VehicleType.TRUCK, FIVE_HOURS)
+ " tiered " + tiered.feeMinor(VehicleType.TRUCK, FIVE_HOURS));
System.out.println(" CAR, 5h flat " + flat.feeMinor(VehicleType.CAR, FIVE_HOURS)
+ " tiered " + tiered.feeMinor(VehicleType.CAR, FIVE_HOURS));
}
/** 4. "Truck" instead of "TRUCK", against the String lot. */
private static void four() {
System.out.println("--- 4. one mis-cased string, against StringlyLot");
StringlyLot lot = new StringlyLot(4);
System.out.println(" free before : " + lot.free());
lot.park("KA01AB1234", "Truck");
System.out.println(" park accepted : yes");
System.out.println(" free after : " + lot.free() + " (a truck takes 2)");
try {
System.out.println(" fee: " + lot.checkout("KA01AB1234", TWO_HOURS));
} catch (RuntimeException thrown) {
System.out.println(" checkout threw : " + thrown.getClass().getName());
System.out.println(" message : " + thrown.getMessage());
System.out.println(" free after refusal : " + lot.free());
}
}
/** 5. The same string, against the typed lot. */
private static void five() {
System.out.println("--- 5. the same string, against Lot");
Lot lot = new Lot(4, new FlatHourlyPricing(FlatHourlyPricing.standardTable()));
System.out.println(" free before : " + lot.free());
try {
lot.park("KA01AB1234", "Truck");
System.out.println(" park accepted : yes");
} catch (RuntimeException thrown) {
System.out.println(" park threw : " + thrown.getClass().getName());
System.out.println(" message : " + thrown.getMessage());
}
System.out.println(" free after : " + lot.free());
lot.park("KA01AB1234", "TRUCK");
System.out.println(" spelled right, free: " + lot.free());
System.out.println(" fee for 2h : " + lot.checkout("KA01AB1234", TWO_HOURS));
}
/** Declared methods, minus the two every enum gets from the compiler. */
private static List<String> declaredMethods(Class<?> type) {
List<String> names = new ArrayList<>();
for (Method m : type.getDeclaredMethods()) {
if (!m.isSynthetic() && !m.getName().equals("values") && !m.getName().equals("valueOf")) {
names.add(m.getName() + "()");
}
}
names.sort(null);
return names;
}
private Main() {}
}
The faded stage is not here, on purpose
In the app, the third stage of a lesson hands you the worked design with a few lines
replaced by // GAP: markers, then compiles your completion and runs a JUnit suite
against it. That needs javac, and a static site has no compiler — so rather than show a
control that cannot work, this page stops at the worked source.
Run the app for the drill: it is the download in the header, and it works offline once unpacked.