Adds IS NOT to the Part II parser and a resolution pass (negation, then
identity lock) that turns parsed rules into the rules in force; harness
gains activeRules() and hasProperty(). A leading NOT now breaks the
sentence instead of being ignored (carried from M6 review). Nouns say
which objects they name ("selects"), so the ALL Expansion Drop is one
line of data. Corrects lesson 06's NOT wording; harness.md M7 details.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
24 KiB
Module 7: Resolving rules: NOT, identity, meta-nouns
Module 6 built a parser that reports everything the board says. This module decides what the board means when its sentences disagree. BABA IS YOU and BABA IS NOT YOU can lie on the same board, and so can BABA IS BABA and BABA IS ROCK. The parser reads them all. A second stage, the resolution pass, reads the whole rule list at once and decides which rules are in force. Then every question an object asks ("am I YOU?") goes through one function that looks only at the rules in force.
Part I had a step like this too. In Module 4, Baron Rivendare changed what a Deathrattle does without the Deathrattle's code knowing he exists: an interceptor, consulted at the moment the trigger runs. BABA IS NOT YOU changes whether BABA IS YOU applies, and nothing in BABA IS YOU knows about it either. Keep both in mind while you build. The course's closing question asks you to compare them.
You need your Module 6 workspace with all Module 1–6 tests passing, under git.
Observe
All quotes come from sources recorded in the course's research notes. The Baba Is You Wiki is an unofficial community wiki; the course treats it as the best available description of the game, not as Hempuli's own word.
What NOT does, from the wiki's NOT page:
When added after a verb it makes the corresponding rule not apply, overriding other instances of the rule it disables. ROCK IS NOT PUSH makes ROCK NOT PUSHable, even if ROCK IS PUSH exists elsewhere.
It works on conversions as well as properties: "ROCK IS NOT GRASS disables ROCK IS GRASS." The IS page says the same about properties: "BABA IS NOT YOU prevents BABA from having the property YOU, overriding any BABA IS YOU rule."
A noun can be its own complement. The IS page:
BABA IS BABA prevents BABA from transforming into any object, including those not in ALL. It does not affect transforming objects into BABA.
And the negation of that, X IS NOT X, from the NOT page:
X IS NOT X makes an object disappear, similarly to X IS EMPTY but with particles. ... As it negates X IS X, it enables other X IS Y rules, which can result in transformation in addition to the disappearance.
The IS page puts it more briefly: "BABA IS NOT BABA causes BABA to vanish in a puff of logic."
Base rules are not exempt. The Rule page says the base rules, TEXT IS PUSH among them, "can still be detected by FEELING and MIMIC and negated by NOT and BROKEN", and the Advanced rulebook relies on it: "Text can be stacked if TEXT IS NOT PUSH can be formed with another way to move text".
Finally, where the game does this. Teikari, in the Game Developer article (Designing Baba Is You's delightfully innovative rule-writing system), describes the last step of building the rules: "Finally, once everything is (hopefully) set, the game goes through the final set of rules and adjusts them if needed." And his example: "(For example, if the rule "Baba Is Not You" exists, "Baba Is You" is marked as being not valid.)"
Notice what he says and what he doesn't. The parser doesn't drop BABA IS YOU when it meets a NOT. The rule is read, kept, and marked not valid, in a pass over the final set.
Predict
Write your answers down before you open the discussion, for example in notes/07.md in your workspace.
BABA IS YOUon the top row andBABA IS NOT YOUtwo rows below. Is Baba YOU? Would the answer change if the rows swapped places?- Three sentences:
BABA IS BABA,BABA IS ROCK,ROCK IS BABA. Which of them are in force? - Three sentences:
BABA IS NOT BABA,BABA IS BABA,BABA IS ROCK. Which are in force, and what happens to Baba? TEXT IS NOT PUSHis on the board. Is the text spelling it pushable? Is a rock?- One row:
NOT BABA IS YOU. Your Module 6 parser reads something from it. What, and is that right? - Back to Part I: Baron makes a Deathrattle activate twice;
BABA IS NOT YOUmakesBABA IS YOUnot apply. In your engines, where is each of those decisions made, and when?
Discussion
-
No, and no.
NOToverrides "other instances of the rule it disables", wherever they are. If your engine let the later sentence win, the answer would depend on which row the parser reads first, and moving a sentence up a row would change the game. Negation is a fact about the whole rule set, not about reading order. -
BABA IS BABAandROCK IS BABA.BABA IS BABAblocks Baba converting into anything, soBABA IS ROCKis out. It doesn't stop rocks becoming Baba. -
BABA IS NOT BABAandBABA IS ROCK.BABA IS NOT BABAis the negation ofBABA IS BABA, so it cancels it exactly asBABA IS NOT YOUcancelsBABA IS YOU; with the lock gone, the conversion applies again. And every Baba vanishes. Which happens to a given Baba first, conversion or vanishing, is a question about the turn (Module 9); this module only has to know that both are in force. -
No, and no. The base rule is a rule like any other;
NOTcancels it, and no text on the board is pushable. A rock was never pushable:TEXTnames text objects, not the things they name. -
BABA IS YOU. The subject run walks back fromIS, findsBABA, finds noANDbefore it, and stops: theNOTis ignored. That reads a sentence that isn't on the board. Module 7's parser has to do something with it. See Build for the course's choice and "How the real game differs" for the game's. -
Baron is consulted while a trigger runs: the step asks the modifiers in play how many times to activate.
NOTis consulted when the rules are compiled, before anyone asks a question: by the time an object asks "am I YOU?", the cancelled rule is already gone. Hold on to that difference; you'll come back to it at the end of the course.
Spec
From the course repository, copy the module's tests into your workspace:
node tools/start-module.mjs 07 ~/my-engine
The harness
Two new functions on Baba, and one change:
| TypeScript | Rust |
|---|---|
activeRules(): string[] |
active_rules(&self) -> Vec<String> |
hasProperty(id, prop): boolean |
has_property(&self, id: Id, prop: &str) -> bool |
rules()still lists everything the text spells, before resolution. It now includes negated rules, written"BABA IS NOT YOU".activeRules()lists the rules in force after resolution, in the same format: sorted, each distinct rule once,"TEXT IS PUSH"included unlessTEXT IS NOT PUSHcancels it.hasProperty(id, prop)answers for one object. A thing is named by its noun (ababaobject byBABA); a text object is named byTEXT.
The tests
The full statements are in course/07-rule-resolution/scenarios.md.
test_07_not_overrides: Predict question 1. Baba is YOU untilBABA IS NOT YOUis written belowBABA IS YOU.rules()shows both sentences;activeRules()shows only the negation and the base rule.test_07_x_is_x_blocks_conversion: Predict question 2, exactly.test_07_x_is_not_x_deletes_and_unlocks: Predict question 3.activeRules()containsBABA IS ROCKand notBABA IS BABA.test_07_text_is_not_push: Predict question 4, checked withhasPropertyon a piece of text and on a rock, before and after.test_07_leading_not_breaks_sentence: Predict question 5.NOT BABA IS YOUspells nothing in the course's grammar.
The third test, which carries the module's subtlest point:
#[test]
fn test_07_x_is_not_x_deletes_and_unlocks() {
let mut b = new_baba(empty_level(4, 5));
write(
&mut b,
&[
("BABA", 0, 0),
("IS", 1, 0),
("NOT", 2, 0),
("BABA", 3, 0),
("BABA", 0, 2),
("IS", 1, 2),
("BABA", 2, 2),
("BABA", 0, 4),
("IS", 1, 4),
("ROCK", 2, 4),
],
);
assert_eq!(
b.rules(),
vec![
"BABA IS BABA",
"BABA IS NOT BABA",
"BABA IS ROCK",
"TEXT IS PUSH"
]
);
let active = b.active_rules();
assert!(active.contains(&"BABA IS ROCK".to_string()));
assert!(!active.contains(&"BABA IS BABA".to_string()));
}
test("test_07_x_is_not_x_deletes_and_unlocks", () => {
const b = newBaba(emptyLevel(4, 5));
write(b, [
["BABA", 0, 0],
["IS", 1, 0],
["NOT", 2, 0],
["BABA", 3, 0],
["BABA", 0, 2],
["IS", 1, 2],
["BABA", 2, 2],
["BABA", 0, 4],
["IS", 1, 4],
["ROCK", 2, 4],
]);
expect(b.rules()).toEqual(["BABA IS BABA", "BABA IS NOT BABA", "BABA IS ROCK", "TEXT IS PUSH"]);
expect(b.activeRules()).toContain("BABA IS ROCK");
expect(b.activeRules()).not.toContain("BABA IS BABA");
});
The test doesn't check that Baba vanishes. Nothing moves or vanishes until the turn exists (Module 9). This module decides that Baba is to vanish; Module 9 does it.
Run cargo test or npm test. The Module 7 tests fail to compile (Rust) or fail (TypeScript). Modules 1–6 still pass. That is RED.
Build
Make the five tests pass, and keep Modules 1–6 green. Where code goes:
- The parser learns
NOT, and only reads. AfterIS, aNOTnegates the complement right after it:BABA IS NOT YOU AND WINisBABA IS NOT YOUandBABA IS WIN. A rule now carries a flag, negated or not. The parser never cancels anything; it reports what is written. - A
NOTbefore a subject breaks the sentence. The course's grammar has no negated subjects. When the subject run meets aNOTin front of a noun, it stops and drops that noun. ReadingNOT BABA IS YOUasBABA IS YOUis the one thing it must not do. - A separate resolution pass. A function from the parsed rules to the rules in force, run after every parse. It sees the whole list at once, so where a sentence sits on the board can't matter.
- One question, one place.
hasProperty(id, prop)looks only at the rules in force, and asks one more function: does this noun name this object?BABAnames objects of kindbaba;TEXTnames every text object.TEXTis the first noun you meet that names a group rather than its own kind. Decide whether that is a special case in your engine or a fact about the word. - Keep the record.
X IS NOT Xmeans "every X vanishes". Nothing vanishes yet, but the decision is made here, and the turn in Module 9 will need to find it.
Hint 1 — nudge
You will be tempted to do it in the parser: when you read BABA IS NOT YOU, delete BABA IS YOU from the list. Try Predict question 1 with the rows swapped. Then try question 3: BABA IS NOT BABA cancels BABA IS BABA, which would have cancelled BABA IS ROCK. How many orders of reading do you have to get right?
Hint 2 — concept
- Two steps, in a fixed order, over the whole list. First negation: drop every positive rule
X IS Yfor which a negatedX IS NOT Yexists. Keep the negated rules: they are still in force. Then identity: for each positiveX IS Xthat survived, drop everyX IS YwhereYis a noun other thanX. - The order does the work for
X IS NOT X. It negatesX IS Xin the first step, so in the second step there is no lock. There's no special case for it. - Telling conversions from properties. The identity step needs to know whether a complement is a noun. The parser knows (the lexer gave every word a role): store it on the rule.
hasPropertyis about objects, not rules. Collect the rules in force whose complement is the property and whose subject names the object. The object has the property if one of them is positive and none is negated. Doing the negation per object, not only per rule, is what lets a negation aboutBABAbeat a positive rule written about a larger group.- Base rules are rules. If
TEXT IS PUSHis in the parsed list like any other rule,TEXT IS NOT PUSHneeds no code of its own.
Hint 3 — pseudo-code
complements(tiles, at, step): // after IS
found = []
branches = [(not negated, at)] + [(negated, at + step) for each NOT in tiles[at]]
for (sign, p) in branches:
words = [w in tiles[p] where w is noun or property]
if words is empty: continue
found += words, each with this sign
if tiles[p + step] has an AND:
found += complements(tiles, p + 2 * step, step)
return found
subjects(tiles, at, step): // before IS, walking backwards
if tiles[at + step] has NOT: return []
... as in Module 6
resolve(rules):
kept = [r in rules where not exists n: n.negated and not r.negated
and n.subject == r.subject and n.complement == r.complement]
locked(x) = exists r in kept: not r.negated and r.subject == x and r.complement == x
return [r in kept where r.negated or r.complement is not a noun
or r.subject == r.complement or not locked(r.subject)]
hasProperty(id, prop):
about = [r in active where r.complement == prop and names(r.subject, object id)]
return any(r positive in about) and not any(r negated in about)
When your tests pass, compare with the reference engine in course/07-rule-resolution/reference/rust/src/engine/puzzle/ or course/07-rule-resolution/reference/ts/src/engine/puzzle/: parse (now with NOT), the new resolve, level (has_property / hasProperty) and word (Lexicon::names).
Decisions in the reference that the tests don't force:
activeRules()keeps negated rules. A negated rule is in force: it keeps overriding. That also makesBABA IS NOT BABAappear inactiveRules(), and that entry is the reference's record that every Baba is to vanish. The sources say Baba vanishes; they don't say how an engine should record it, so the test doesn't check this.X IS NOT YwithYa noun is just a negation. It cancelsX IS Yand nothing else, as the NOT page says ofROCK IS NOT GRASS.- Negation is settled twice. Once per rule in the resolution pass (
activeRules()dropsBABA IS YOU), and once per object inhasProperty. Only the second matters when the subjects differ, which the Expansion Drop shows. - Which objects a noun names is a fact about the word. The reference's word list has an optional field,
"selects":"named"(the default: the noun's own things),"text"(every text object) or"things"(every object that isn't text). The engine registersTEXTwith"text".Lexicon::names/Lexicon.namesreads the field. - One
NOTonly.IS NOT NOT PUSHspells nothing (see the box below). - Duplicates as before. The engine keeps a rule once per set of text objects, now including the
NOT; the harness deduplicates.
How the real game differs
NOTbefore a noun is real grammar in the game. The TEXT page says "TEXT is not included in ALL, but is included in NOT ALL", soNOT ALLnames a group. The course's parser doesn't read negated subjects: it refuses the sentence rather than misread it. baba-is-auto makes the same cut: "Negated subjects, predicates, condition targets, repeatedNOT, and nested forms are unsupported" (rule-language.md).NOTcan stack: "NOT negates itself before negating anything else, so ROCK IS NOT NOT PUSH makes ROCK IS PUSH" (NOT). The course reads oneNOT.X IS NOT Xmakes X disappear "similarly to X IS EMPTY but with particles" (same page). The course has noEMPTY, and the disappearance waits for Module 9's turn.- Base rules can also be negated by
BROKEN(Rule). The course has noBROKEN.BABA IS BABAblocks every conversion of Baba, "including those not in ALL" (IS). The course has no conversion intoALL, and the wiki's Order of Operations givesX IS ALLits own place in the turn ("X IS ALL takes the lowest priority").
Name it
The pattern is a resolution pass (a post-pass): parse everything into candidate rules, then resolve the conflicts in one deterministic pass over the whole set, and let every query read only the result. The parser stays simple because it never decides anything. The resolver stays simple because it never reads the board. And every system that asks "is Baba YOU?" gets the same answer, because there is exactly one place that answers.
The reference's whole resolver:
pub fn resolve(rules: &[Rule]) -> Vec<Rule> {
let survives_negation: Vec<&Rule> = rules
.iter()
.filter(|r| !rules.iter().any(|n| n.negates(r)))
.collect();
let locked = |subject: &str| {
survives_negation
.iter()
.any(|r| !r.negated && r.is_identity() && r.subject == subject)
};
survives_negation
.iter()
.filter(|r| r.negated || !r.is_conversion() || r.is_identity() || !locked(&r.subject))
.map(|&r| r.clone())
.collect()
}
export function resolve(rules: readonly Rule[]): Rule[] {
const survivesNegation = rules.filter((r) => !rules.some((n) => negates(n, r)));
const locked = (subject: string) =>
survivesNegation.some((r) => !r.negated && isIdentity(r) && r.subject === subject);
return survivesNegation.filter((r) => r.negated || !isConversion(r) || isIdentity(r) || !locked(r.subject));
}
The order of the two steps is part of the meaning. Swap them and BABA IS NOT BABA no longer unlocks BABA IS ROCK, because the lock would already have removed it.
Magic: The Gathering has the large version of this. When many continuous effects change the same object, rule 613.1 of the Comprehensive Rules says: "Then all applicable continuous effects are applied in a series of layers in the following order", and inside a layer, "determining which order effects are applied in is usually done using a timestamp system" (613.7) and "is sometimes done using a dependency system" (613.8) (Comprehensive Rules). The shape is the same as yours: collect every effect first, then apply them in an order fixed by the rules, not by where the cards sit. Your resolver has two layers, negation and identity, and needs no timestamps, because a rule's position on the board never matters.
Compare it with Module 4. Baron's interceptor is consulted at the moment a Deathrattle runs, and it changes how many times something happens. NOT is applied when the rules are compiled, and it changes whether a rule exists at all. Both keep the thing they modify ignorant of them. Which one would you rather debug? The course's closing question returns to Baron and BABA IS NOT YOU; keep your notes.
Run the coupling audit again: node tools/coupling-audit.mjs ~/my-engine should print coupling-audit: ok. The resolver names no noun: it compares subjects and complements, whatever they spell.
Expansion Drop
A new word arrives: ALL, a meta-noun. As a subject it names every object on the board that is not text: the TEXT page is explicit that "TEXT is not included in ALL".
The constraint, as always: do not edit anything under src/engine/. The harness doesn't change either. Only your word list should.
- Commit your workspace and note the commit:
git add -A && git commit -m "module 7 done". - Copy the expansion test for your track from
course/07-rule-resolution/expansion/tests/rust/test_07x.rsorcourse/07-rule-resolution/expansion/tests/ts/test_07x.test.tsinto your workspace'stests/. Run it and watch it fail. - Add
ALLto your word list. - When the tests pass, run the check from the course repository:
node tools/expansion-check.mjs ~/my-engine HEAD
The test, test_07x_all_meta_noun, writes ALL IS YOU with a baba, a rock and a piece of text on the board. Baba and the rock are YOU; the text is not. Then it writes BABA IS NOT YOU: Baba stops being YOU, the rock doesn't. The full scenario is in course/07-rule-resolution/expansion/README.md.
Discussion: what this drop measures
If "which objects does this noun name" is a fact about the word, ALL is one line:
{ "name": "ALL", "role": "noun", "selects": "things" }
That is the reference answer, in course/07-rule-resolution/expansion/solution/words.json (the Module 7 word list with that line added), the same file for both tracks.
Shapes that fail this drop:
TEXTas a branch in the engine. IfhasPropertysaysif subject == TEXT { object is text } else { object's noun == subject }, thenALLis a third branch, undersrc/engine/. The test can't pass without an engine change. The fix is to move the question "which objects does this noun name" into the word list, and letTEXTbe the first entry that uses it. Make it a normal commit, then redo the drop.- Negation only per rule. If your resolver cancels
X IS Yonly when a ruleX IS NOT Yhas the very same subject, andhasPropertythen trusts any positive rule,ALL IS YOUsurvives and Baba stays YOU afterBABA IS NOT YOU. The second half of the test fails. The negation has to be checked for the object, against every noun that names it. - Expanding
ALLin the parser. TurningALL IS YOUintoBABA IS YOU,ROCK IS YOU, ... for every noun in the word list works for this test, but the parser now needs to know the board's objects, andactiveRules()shows rules nobody wrote. Keep the parser about text.
If the check fails, fix your engine (a normal commit, outside the drop), then redo the drop from step 1.
Further reading
- The Baba Is You Wiki's NOT and IS pages: every case in this module, and the ones the course leaves out (stacked
NOT,NOTbefore nouns). Then TEXT forTEXT IS PUSHandALL. - John Harris, Designing Baba Is You's delightfully innovative rule-writing system, Game Developer, 8 May 2019: Teikari on the final pass over the rule set, the pass you just built.
- Wizards of the Coast, Magic: The Gathering Comprehensive Rules, rule 613 (613.1, 613.7, 613.8): interaction of continuous effects, in layers, by timestamp and by dependency. A resolution pass for a game with thousands of cards.
- Argentum,
docs/architecture-principles.md, §2.3 "Rule 613: Base State vs. Projected State": "the engine doesn't need to 'undo' +3/+3. It simply removes the floating effect." The same idea as recompiling the rules from the board. - baba-is-auto,
Game::ParseRulesinSources/baba-is-auto/Games/Game.cpp(around line 611) andRuleManager::HasPropertyinSources/baba-is-auto/Rules/RuleManager.cpp: one place that answers "does this object have this property?". It is an example of rules as data, not an authority on the game: it has noIS NOTfor properties or nouns and hard-codes text as pushable (rule-language.md), so it can't run this module's tests.