On this page
Compositions a subsystem as one unit๏
๐ต Expert ยท Lesson 14
COMPOSITIONS - naming a SET of spells as one thing, and then asking questions about the thing instead of about its members.
Expert 03 taught residency: a spell lives in exactly one lane. That answers "where is this one". It does not answer "what is our billing subsystem", because a subsystem is not a spell - it is a SET of them that someone decided to treat as a unit.
THE EIGHT VERBS, AND TWO DIFFERENT SPLITS RUN THROUGH THEM
SPLIT ONE - BY AUTHORITY (which room may call it)
ORGANIZE (codegen rooms only - these WRITE) research_group_register(member_spell_ids, lane=..., reason=...) research_group_recompose(previous_group_id, add=..., remove=...)
READ (capability rooms get these too) research_group_view(group_id) research_group_diff(left, right, strategy=...) research_group_impact(group_id) research_group_footprint(group_id) research_group_drift(group_id) research_group_history(group_id, campaign=...)
Six reads, two writes. That is expert 08's law one grain up: a capability room can read the ENTIRE composition record and cannot change one thing in it. Only a codegen room may restate what a subsystem IS - because restating it changes what the next reader concludes about work nobody has redone.
SPLIT TWO - BY DEPENDENCY (what the read has to reach) The six reads are NOT the same kind of question, and finding this out the hard way is the reason this lesson exists in its current form. Three answer from the RESEARCH RECORD alone:
view the roster and its lane joins history the journal story diff two rosters, plus lane-evidenced version moves
Three are FORESIGHT reads that join research truth to CUSTODY material, and they reach through the Crystallizer to do it:
footprint the physical module shadow impact the union blast radius and closure math drift recorded-vs-disk, narrowed to the footprint
A FORESIGHT READ WITH NO LIVE CRYSTALLIZER REFUSES. It does not return an empty report, and melder says why in the message: "foresight reads need the record - activate the crystallizer before asking for source, impact, or module graphs." Never- substitute, on a read path.
AND THE TWO ABSENCES ARE DIFFERENT, WHICH IS THE SUBTLE PART instrument OFF -> refusal (no crystallizer: cannot answer) instrument ON, nothing to read -> data (members with no custody crystal come back under unknown_custody_members) "I cannot answer" and "the answer is none" are not the same fact, and melder refuses to spell them the same way.
THE ID IS THE MEMBERSHIP, NOT A SERIAL NUMBER group_id is a sha256 over the SORTED, DEDUPED member list. Three consequences follow, and none of them are cosmetic:
A composition is a SET. Input order and duplicates cannot change its identity, because they are canonicalised away before the hash. - THE SAME ROSTER IS THE SAME COMPOSITION. Not a copy of it - it. So re-declaring an unchanged set REFUSES as a rediscovery and the error names the lane already holding it. You cannot accidentally end up with two names for one subsystem. - A recompose that resolves back to the previous roster refuses for exactly the same reason. Remove-then-re-add is a no-op, and melder will not record a no-op as history.
RECOMPOSE SUCCEEDS THE OLD ONE, IT NEVER EDITS IT A new node is registered with parent_group_ids=[previous], in the SAME lane, and the previous composition is untouched. Forward-only: the old answer stays exactly as true as it was, and the timeline is walkable instead of lossy.
WHAT COMES BACK IS A PAYLOAD, NOT A NODE Both writes return node.describe() - a plain dict carrying node_type, group_id, member_spell_ids, parent_group_ids, author, reason, campaign, created_at, metadata. Detached and plain-value, so holding one cannot mutate the record.
AND THE JOIN THAT MAKES IT USEFUL research_group_history(group_id, campaign=...) History is WHEN. Campaign is WHY. Passing both is the WHERE-by-WHEN join - "how did this subsystem change during that effort" - which is the question you actually have during a review, and the one you cannot ask if a set is only ever a list you kept in your head.
IMPACT IS LIFTED TOO. research_impact on a single spell names the GroupedResearchNode subsystems it touches under affected_compositions. Blast radius stopped being a list of files and became a list of things with names.
Before you run๏
Use the Expert guide for prerequisite concepts. Run from a checkout with Melder installed and Python 3.14 free-threading selected. The collection download includes the level's local helper modules.
Run the saved script๏
python UX_and_AIX_experiences/04_expert/14_compositions_a_subsystem_as_one_unit.py
py -3.14t UX_and_AIX_experiences/04_expert/14_compositions_a_subsystem_as_one_unit.py
Public surface๏
the eight research_group_* commands across a codegen room and a capability room, the read/write line between them, and the record/foresight line inside the reads
Code๏
1"""
2TIER: expert (14)
3GOAL: COMPOSITIONS - naming a SET of spells as one thing, and then
4 asking questions about the thing instead of about its members.
5
6 Expert 03 taught residency: a spell lives in exactly one lane.
7 That answers "where is this one". It does not answer "what is our
8 billing subsystem", because a subsystem is not a spell - it is a
9 SET of them that someone decided to treat as a unit.
10
11 THE EIGHT VERBS, AND TWO DIFFERENT SPLITS RUN THROUGH THEM
12
13 SPLIT ONE - BY AUTHORITY (which room may call it)
14
15 ORGANIZE (codegen rooms only - these WRITE)
16 research_group_register(member_spell_ids, lane=..., reason=...)
17 research_group_recompose(previous_group_id, add=..., remove=...)
18
19 READ (capability rooms get these too)
20 research_group_view(group_id)
21 research_group_diff(left, right, strategy=...)
22 research_group_impact(group_id)
23 research_group_footprint(group_id)
24 research_group_drift(group_id)
25 research_group_history(group_id, campaign=...)
26
27 Six reads, two writes. That is expert 08's law one grain up: a
28 capability room can read the ENTIRE composition record and cannot
29 change one thing in it. Only a codegen room may restate what a
30 subsystem IS - because restating it changes what the next reader
31 concludes about work nobody has redone.
32
33 SPLIT TWO - BY DEPENDENCY (what the read has to reach)
34 The six reads are NOT the same kind of question, and finding this
35 out the hard way is the reason this lesson exists in its current
36 form. Three answer from the RESEARCH RECORD alone:
37
38 view the roster and its lane joins
39 history the journal story
40 diff two rosters, plus lane-evidenced version moves
41
42 Three are FORESIGHT reads that join research truth to CUSTODY
43 material, and they reach through the Crystallizer to do it:
44
45 footprint the physical module shadow
46 impact the union blast radius and closure math
47 drift recorded-vs-disk, narrowed to the footprint
48
49 A FORESIGHT READ WITH NO LIVE CRYSTALLIZER REFUSES. It does not
50 return an empty report, and melder says why in the message:
51 "foresight reads need the record - activate the crystallizer
52 before asking for source, impact, or module graphs." Never-
53 substitute, on a read path.
54
55 AND THE TWO ABSENCES ARE DIFFERENT, WHICH IS THE SUBTLE PART
56 instrument OFF -> refusal (no crystallizer: cannot answer)
57 instrument ON,
58 nothing to read -> data (members with no custody crystal
59 come back under
60 `unknown_custody_members`)
61 "I cannot answer" and "the answer is none" are not the same fact,
62 and melder refuses to spell them the same way.
63
64 THE ID IS THE MEMBERSHIP, NOT A SERIAL NUMBER
65 `group_id` is a sha256 over the SORTED, DEDUPED member list. Three
66 consequences follow, and none of them are cosmetic:
67
68 - A composition is a SET. Input order and duplicates cannot
69 change its identity, because they are canonicalised away
70 before the hash.
71 - THE SAME ROSTER IS THE SAME COMPOSITION. Not a copy of it -
72 it. So re-declaring an unchanged set REFUSES as a rediscovery
73 and the error names the lane already holding it. You cannot
74 accidentally end up with two names for one subsystem.
75 - A recompose that resolves back to the previous roster refuses
76 for exactly the same reason. Remove-then-re-add is a no-op, and
77 melder will not record a no-op as history.
78
79 RECOMPOSE SUCCEEDS THE OLD ONE, IT NEVER EDITS IT
80 A new node is registered with `parent_group_ids=[previous]`, in the
81 SAME lane, and the previous composition is untouched. Forward-only:
82 the old answer stays exactly as true as it was, and the timeline is
83 walkable instead of lossy.
84
85 WHAT COMES BACK IS A PAYLOAD, NOT A NODE
86 Both writes return `node.describe()` - a plain dict carrying
87 `node_type`, `group_id`, `member_spell_ids`, `parent_group_ids`,
88 `author`, `reason`, `campaign`, `created_at`, `metadata`. Detached
89 and plain-value, so holding one cannot mutate the record.
90
91 AND THE JOIN THAT MAKES IT USEFUL
92 research_group_history(group_id, campaign=...)
93 History is WHEN. Campaign is WHY. Passing both is the WHERE-by-WHEN
94 join - "how did this subsystem change during that effort" - which
95 is the question you actually have during a review, and the one you
96 cannot ask if a set is only ever a list you kept in your head.
97
98 IMPACT IS LIFTED TOO. `research_impact` on a single spell names the
99 GroupedResearchNode subsystems it touches under
100 `affected_compositions`. Blast radius stopped being a list of files
101 and became a list of things with names.
102SURFACE EXERCISED: the eight research_group_* commands across a codegen
103 room and a capability room, the read/write line
104 between them, and the record/foresight line inside the
105 reads
106VERIFY: RUN GREEN 2026-08-03 on the owner's 3.14t harness.
107"""
108import melder as md
109
110
111WRITES = ("research_group_register", "research_group_recompose")
112# Answered from the research record alone.
113RECORD_READS = (
114 "research_group_view", "research_group_history", "research_group_diff",
115)
116# Joined against custody material - these need a live Crystallizer.
117FORESIGHT_READS = (
118 "research_group_footprint", "research_group_impact",
119 "research_group_drift",
120)
121READS = RECORD_READS + FORESIGHT_READS
122
123
124def _room(nexus, kind, name):
125 """Open one rift of the given kind and hand back its command surface."""
126 configuration = nexus.create_rift_configuration()
127 configuration.with_space_type(kind)
128 rift = nexus.create_rift(configuration=configuration, rift_name=name)
129 rift.mark_active()
130 return rift.space.command_system
131
132
133def main() -> None:
134 nexus = md.Nexus()
135 system_configuration = nexus.create_configuration()
136 system_configuration.with_rift_creation_enabled(True)
137 nexus.activate(system_configuration)
138
139 # The research family reaches the Aether-hosted root through a
140 # NON-CONSTRUCTING peek, so it must be live before any of this answers.
141 research = md.MutationResearch()
142 research_configuration = research.create_configuration()
143 research_configuration.with_defaults().activate()
144 research.activate(research_configuration)
145 print("research root live -", research.activated)
146
147 codegen = _room(nexus, "codegen", "composer")
148 capability = _room(nexus, "capability", "reviewer")
149
150 # THE SPLIT, MEASURED. Six reads in both rooms; two writes in one.
151 print()
152 print("composition surface by room kind:")
153 for verb in READS:
154 assert hasattr(codegen, verb), verb
155 assert hasattr(capability, verb), verb
156 print(f" reads codegen {len(READS)} capability {len(READS)}")
157 for verb in WRITES:
158 assert hasattr(codegen, verb), verb
159 assert not hasattr(capability, verb), (
160 f"capability gained {verb} - the read/write line moved"
161 )
162 print(f" writes codegen {len(WRITES)} capability 0")
163 print()
164 print("a capability room reads the WHOLE record and changes nothing")
165
166 # DECLARE A SUBSYSTEM. Members must already be resident - a composition
167 # PINS declared versions, it does not introduce them.
168 research_set = research.research_set()
169 invoices = "a" * 64
170 payments = "b" * 64
171 research_set.register_spell(invoices)
172 research_set.register_spell(payments)
173 research_set.create_lane("billing", lane_type="production")
174
175 payload = codegen.research_group_register(
176 [invoices, payments], lane="billing", reason="billing subsystem",
177 )
178 # WHAT CAME BACK IS A describe() PAYLOAD - a detached plain dict, not
179 # the node. Holding it cannot reach in and change the record.
180 assert payload["node_type"] == "group"
181 group_id = payload["group_id"]
182 assert payload["member_spell_ids"] == sorted([invoices, payments])
183 assert payload["parent_group_ids"] == []
184 print()
185 print("registered a composition ->", group_id[:12], "...")
186 print(" members come back SORTED - a composition is a SET")
187
188 # THE ID IS THE MEMBERSHIP. Declaring the same roster again is not a
189 # second subsystem, it is the SAME one - so it refuses and says where
190 # the original lives.
191 try:
192 codegen.research_group_register(
193 [payments, invoices], lane="billing", reason="same set, again",
194 )
195 raise AssertionError("expected a rediscovery refusal")
196 except RuntimeError as error:
197 print()
198 print("re-declaring the same roster refused -", error)
199 print(" order reversed, identity identical: it hashes the SET")
200
201 # RECOMPOSE SUCCEEDS THE OLD ONE. New id, previous recorded as parent,
202 # same lane - and the old composition still resolves untouched.
203 refunds = "c" * 64
204 research_set.register_spell(refunds)
205 second = codegen.research_group_recompose(
206 group_id, add=[refunds], reason="refunds joined billing",
207 )
208 second_id = second["group_id"]
209 assert second_id != group_id, (
210 "a recompose must mint a NEW id - overwriting would destroy the "
211 "old answer, and the old answer was never wrong"
212 )
213 assert second["parent_group_ids"] == [group_id], (
214 "the new composition must record what it evolved FROM"
215 )
216 assert len(second["member_spell_ids"]) == 3
217 print()
218 print("recomposed -> new id, parent =", group_id[:12], "...")
219 assert codegen.research_group_view(group_id) is not None
220 print(" the previous composition still resolves, still true")
221
222 # A RECOMPOSE THAT CHANGES NOTHING IS NOT HISTORY. Remove a member and
223 # add it straight back and you are describing the roster you already
224 # have - same content, same identity, nothing to record.
225 try:
226 codegen.research_group_recompose(
227 second_id, add=[refunds], remove=[refunds], reason="no-op",
228 )
229 raise AssertionError("expected a refusal: unchanged roster")
230 except RuntimeError as error:
231 print()
232 print("a no-op recompose refused -", type(error).__name__)
233 print(" melder will not write 'nothing happened' into a timeline")
234
235 # COMPOSITIONS DO NOT NEST. A group id is a real identity in the same
236 # sha namespace, but it names no code - so it cannot be a member.
237 try:
238 codegen.research_group_register(
239 [second_id, invoices], lane="billing", reason="nesting",
240 )
241 raise AssertionError("expected a refusal: composition as member")
242 except ValueError as error:
243 print("a composition cannot be a member of a composition -")
244 print(" ", error)
245
246 # THE RECORD READS, FROM THE ROOM THAT CANNOT WRITE. These need
247 # nothing but the research record, so they answer right now.
248 print()
249 print("capability room, RECORD reads (research record only):")
250 print(" view ->",
251 type(capability.research_group_view(second_id)).__name__)
252 print(" history ->",
253 type(capability.research_group_history(second_id)).__name__)
254 print(" diff ->",
255 type(capability.research_group_diff(group_id, second_id)).__name__)
256
257 # THE FORESIGHT READS REFUSE UNTIL CUSTODY IS RECORDING. The
258 # crystallizer exists - Aether built it - but existing is not being
259 # live, and melder will not answer a physical question from an
260 # instrument that is switched off.
261 print()
262 print("capability room, FORESIGHT reads with custody not recording:")
263 for verb in FORESIGHT_READS:
264 try:
265 getattr(capability, verb)(second_id)
266 raise AssertionError(f"{verb} must refuse without custody")
267 except RuntimeError as error:
268 assert "crystallizer" in str(error).lower()
269 print(f" {verb.split('_')[-1]:<9} -> refused")
270 print(" 'cannot answer' is not 'the answer is none' - so it raises")
271 print(" instead of handing back an empty report")
272
273 # SWITCH THE INSTRUMENT ON. Same ladder as everywhere else: the
274 # configuration activates first, then the subsystem.
275 crystallizer = md.Crystallizer()
276 crystallizer.activate(
277 md.CrystallizerConfigurationBuilder().with_defaults().activate(),
278 )
279 assert crystallizer.activated is True
280 print()
281 print("crystallizer activated - custody is recording now")
282
283 print()
284 print("capability room, the SAME three foresight reads:")
285 for verb in FORESIGHT_READS:
286 answer = getattr(capability, verb)(second_id)
287 print(f" {verb.split('_')[-1]:<9} ->", type(answer).__name__)
288
289 # AND NOW THE OTHER KIND OF ABSENCE. These members were declared by
290 # id and never bound, so no custody crystal exists for any of them.
291 # That is DATA, and the footprint names them rather than pretending.
292 footprint = capability.research_group_footprint(second_id)
293 assert set(footprint["unknown_custody_members"]) == set(
294 second["member_spell_ids"]
295 ), "every member was declared by id and never bound, so all are unknown"
296 print()
297 print("footprint reports", len(footprint["unknown_custody_members"]),
298 "members with no custody crystal, BY NAME")
299 print(" instrument off -> refusal. nothing to read -> data.")
300 print(" two different absences, spelled two different ways")
301
302 print()
303 print("a subsystem is a SET someone named, not a folder")
304 print("the id IS the membership - so the same roster is the same thing")
305 print("recompose succeeds the old answer instead of erasing it")
306 print("only the room that may write code may restate what a thing IS")
307
308
309if __name__ == "__main__":
310 main()
Check the outcome๏
The script contains its own assertions or demonstrated refusal paths. Run it to evaluate those checks against your installed version. The code above is taken directly from the saved file; no run output is invented here.