cellgraph 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cellgraph/analysis.py ADDED
@@ -0,0 +1,1013 @@
1
+ """Cell analysis and the DAG (design 4.4 and 4.5).
2
+
3
+ ``analyze`` runs the phases in a fixed order: parse, names, groups, wiring,
4
+ outputs, keys. It always returns an analysis an editor can show. A cell carries
5
+ ``plannable``, which is false for its own errors, for a structural error it is
6
+ part of (a cycle, a variant collision) and for any cell it depends on that is not
7
+ plannable. ``Analysis.graph`` holds every candidate edge; ``Analysis.dag`` keeps
8
+ only plannable cells.
9
+
10
+ The wiring is Strata's ``NotebookDag.from_cells``, ported as is.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import ast
16
+ import difflib
17
+ import math
18
+ from collections import deque
19
+ from collections.abc import Callable, Iterable, Mapping, Sequence
20
+ from dataclasses import dataclass, field, replace
21
+ from typing import Any
22
+
23
+ from cellgraph.diagnostics import Diagnostic, Severity
24
+ from cellgraph.effects import (
25
+ EFFECT_TYPES,
26
+ After,
27
+ Binds,
28
+ Defines,
29
+ Effect,
30
+ FanOut,
31
+ Input,
32
+ Keeps,
33
+ Member,
34
+ NoCache,
35
+ Reads,
36
+ Seeds,
37
+ )
38
+ from cellgraph.grammar import Directive
39
+ from cellgraph.languages import (
40
+ Analyzed,
41
+ LanguageAnalyzer,
42
+ directive_keys,
43
+ registered_analyzers,
44
+ )
45
+ from cellgraph.vocabulary import AnnotationError, Partial, ValidationContext, Vocabulary
46
+
47
+ NORMALIZE_LEGACY_1 = "legacy-1"
48
+ NORMALIZE_CELLGRAPH_1 = "cellgraph-1"
49
+ DIGEST_LEGACY_1 = "legacy-1"
50
+ DIGEST_CELLGRAPH_1 = "cellgraph-1"
51
+
52
+ # Diagnostics that make a structural error: the cell is on a cycle or in a collision.
53
+ STRUCTURAL_CODES = frozenset({"cycle", "variant_collision"})
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class Cell:
58
+ """A cell as the host gives it. ``meta`` is the host's, passed to ``cell_effects``."""
59
+
60
+ id: str
61
+ source: str
62
+ language: str = "python"
63
+ meta: Mapping[str, Any] = field(default_factory=dict)
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class GroupConfig:
68
+ """Per variant group: its mode (``switch`` or ``sweep``) and its active member's name."""
69
+
70
+ modes: Mapping[str, str] = field(default_factory=dict)
71
+ active: Mapping[str, str] = field(default_factory=dict)
72
+
73
+
74
+ class NotPlannableError(ValueError):
75
+ """A key or a plan was asked of a cell that is not plannable."""
76
+
77
+ def __init__(self, cell_id: str, diagnostics: Sequence[Diagnostic]):
78
+ reasons = "; ".join(f"{d.cell}: {d.code}" for d in diagnostics) or "no diagnostic"
79
+ super().__init__(f"cell {cell_id} is not plannable ({reasons})")
80
+ self.cell_id = cell_id
81
+ self.diagnostics = list(diagnostics)
82
+
83
+
84
+ # --- the DAG ----------------------------------------------------------------------
85
+
86
+
87
+ @dataclass
88
+ class DagEdge:
89
+ """``variable`` flows from ``from_cell_id`` to ``to_cell_id``; empty for an ordering edge."""
90
+
91
+ from_cell_id: str
92
+ to_cell_id: str
93
+ variable: str
94
+
95
+
96
+ @dataclass
97
+ class VariantGroupResolution:
98
+ """One variant group: its members in source order, inactive ones included."""
99
+
100
+ group: str
101
+ active_name: str
102
+ active_cell_id: str
103
+ members: list[tuple[str, str]] = field(default_factory=list)
104
+ mode: str = "switch"
105
+
106
+
107
+ @dataclass(frozen=True)
108
+ class SweepProducer:
109
+ """A variable produced across a set of variants.
110
+
111
+ - **Sweep group** (``fanout_cell is None``): ``variants`` maps
112
+ ``variant_name -> member_cell_id``; a reader takes one edge per member.
113
+ - **Fan-out cell** (``fanout_cell`` set): every entry points at ``fanout_cell``,
114
+ which runs once per variant.
115
+ """
116
+
117
+ group: str
118
+ variants: tuple[tuple[str, str], ...]
119
+ fanout_cell: str | None = None
120
+
121
+
122
+ @dataclass
123
+ class Dag:
124
+ """The wired graph over active cells."""
125
+
126
+ edges: list[DagEdge] = field(default_factory=list)
127
+ cell_upstream: dict[str, list[str]] = field(default_factory=dict)
128
+ cell_downstream: dict[str, list[str]] = field(default_factory=dict)
129
+ leaves: set[str] = field(default_factory=set)
130
+ roots: set[str] = field(default_factory=set)
131
+ topological_order: list[str] = field(default_factory=list)
132
+ variable_producer: dict[str, str | SweepProducer] = field(default_factory=dict)
133
+ consumed_variables: dict[str, set[str]] = field(default_factory=dict)
134
+ shadow_warnings: dict[str, list[str]] = field(default_factory=dict)
135
+ variant_groups: list[VariantGroupResolution] = field(default_factory=list)
136
+ inactive_cells: set[str] = field(default_factory=set)
137
+ wired_inputs: dict[str, dict[str, set[str]]] = field(default_factory=dict)
138
+ fanout_cells: dict[str, SweepProducer] = field(default_factory=dict)
139
+ read_producers: dict[str, dict[str, str | SweepProducer]] = field(default_factory=dict)
140
+
141
+ def wired_variables(self, consumer_id: str, producer_id: str) -> set[str]:
142
+ """The variables ``consumer_id`` reads from ``producer_id``."""
143
+ return self.wired_inputs.get(consumer_id, {}).get(producer_id, set())
144
+
145
+ def read_producer(self, consumer_id: str, var: str) -> str | SweepProducer | None:
146
+ """What ``consumer_id`` reads ``var`` from; its last producer for a name it never reads."""
147
+ reads = self.read_producers.get(consumer_id, {})
148
+ return reads[var] if var in reads else self.variable_producer.get(var)
149
+
150
+ def sweep_groups_read(self, consumer_id: str) -> set[str]:
151
+ """The groups whose sweep (or fan-out over it) ``consumer_id`` reads."""
152
+ return {
153
+ producer.group
154
+ for producer in self.read_producers.get(consumer_id, {}).values()
155
+ if isinstance(producer, SweepProducer)
156
+ }
157
+
158
+ def upstream_reachable(self, start: str) -> set[str]:
159
+ """The cells reachable upstream from ``start``, ``start`` included."""
160
+ visited: set[str] = set()
161
+ queue: deque[str] = deque([start])
162
+ while queue:
163
+ current = queue.popleft()
164
+ if current in visited:
165
+ continue
166
+ visited.add(current)
167
+ for neighbor in self.cell_upstream.get(current, []):
168
+ if neighbor not in visited:
169
+ queue.append(neighbor)
170
+ return visited
171
+
172
+
173
+ def _kahn(dag: Dag, cell_ids: list[str]) -> list[str]:
174
+ """Topological order of *cell_ids*; cells on or after a cycle are left out."""
175
+ members = set(cell_ids)
176
+ degree = {cid: sum(1 for u in dag.cell_upstream[cid] if u in members) for cid in cell_ids}
177
+ queue: deque[str] = deque(cid for cid in cell_ids if degree[cid] == 0)
178
+ order: list[str] = []
179
+ while queue:
180
+ current = queue.popleft()
181
+ order.append(current)
182
+ for downstream in dag.cell_downstream[current]:
183
+ if downstream not in members:
184
+ continue
185
+ degree[downstream] -= 1
186
+ if degree[downstream] == 0:
187
+ queue.append(downstream)
188
+ return order
189
+
190
+
191
+ def _cycles(dag: Dag, cell_ids: list[str]) -> list[list[str]]:
192
+ """Every cycle the depth-first walk finds, each as the ids along it (Strata's order)."""
193
+ color = {cid: 0 for cid in cell_ids}
194
+ cycles: list[list[str]] = []
195
+
196
+ def dfs(node: str, path: list[str]) -> None:
197
+ color[node] = 1
198
+ path.append(node)
199
+ for downstream in dag.cell_downstream[node]:
200
+ if color.get(downstream) == 1:
201
+ start = path.index(downstream)
202
+ cycles.append(path[start:] + [downstream])
203
+ elif color.get(downstream) == 0:
204
+ dfs(downstream, path)
205
+ path.pop()
206
+ color[node] = 2
207
+
208
+ for cid in cell_ids:
209
+ if color[cid] == 0:
210
+ dfs(cid, [])
211
+ return cycles
212
+
213
+
214
+ def _on_cycles(dag: Dag, cell_ids: list[str]) -> set[str]:
215
+ """The cells in a strongly connected component of more than one cell."""
216
+ index: dict[str, int] = {}
217
+ low: dict[str, int] = {}
218
+ stack: list[str] = []
219
+ on_stack: set[str] = set()
220
+ found: set[str] = set()
221
+ counter = [0]
222
+ members = set(cell_ids)
223
+
224
+ def strong(v: str) -> None:
225
+ index[v] = low[v] = counter[0]
226
+ counter[0] += 1
227
+ stack.append(v)
228
+ on_stack.add(v)
229
+ for w in dag.cell_downstream[v]:
230
+ if w not in members:
231
+ continue
232
+ if w not in index:
233
+ strong(w)
234
+ low[v] = min(low[v], low[w])
235
+ elif w in on_stack:
236
+ low[v] = min(low[v], index[w])
237
+ if low[v] == index[v]:
238
+ component = []
239
+ while True:
240
+ w = stack.pop()
241
+ on_stack.discard(w)
242
+ component.append(w)
243
+ if w == v:
244
+ break
245
+ if len(component) > 1:
246
+ found.update(component)
247
+
248
+ for cid in cell_ids:
249
+ if cid not in index:
250
+ strong(cid)
251
+ return found
252
+
253
+
254
+ def _wire(dag: Dag, from_id: str, to_id: str, var: str) -> None:
255
+ dag.edges.append(DagEdge(from_cell_id=from_id, to_cell_id=to_id, variable=var))
256
+ if from_id not in dag.cell_upstream[to_id]:
257
+ dag.cell_upstream[to_id].append(from_id)
258
+ if to_id not in dag.cell_downstream[from_id]:
259
+ dag.cell_downstream[from_id].append(to_id)
260
+ dag.consumed_variables[from_id].add(var)
261
+ dag.wired_inputs.setdefault(to_id, {}).setdefault(from_id, set()).add(var)
262
+
263
+
264
+ # --- per-cell analysis --------------------------------------------------------------
265
+
266
+
267
+ @dataclass
268
+ class CellAnalysis:
269
+ """One cell after every phase."""
270
+
271
+ id: str
272
+ language: str
273
+ source: str
274
+ # Parsed value of each annotation present on the cell, by key.
275
+ annotations: dict[str, Any] = field(default_factory=dict)
276
+ directives: list[Directive] = field(default_factory=list)
277
+ effects: list[Effect] = field(default_factory=list)
278
+ defines: list[str] = field(default_factory=list)
279
+ references: list[str] = field(default_factory=list)
280
+ builtin_references: list[str] = field(default_factory=list)
281
+ mutation_defines: list[str] = field(default_factory=list)
282
+ bound: list[str] = field(default_factory=list)
283
+ after: list[str] = field(default_factory=list)
284
+ seeds: dict[str, str] = field(default_factory=dict)
285
+ member: Member | None = None
286
+ fanout: FanOut | None = None
287
+ kept: list[str] = field(default_factory=list)
288
+ inputs: list[Input] = field(default_factory=list)
289
+ nocache: bool = False
290
+ key_fields: dict[str, Any] = field(default_factory=dict)
291
+ diagnostics: list[Diagnostic] = field(default_factory=list)
292
+ plannable: bool = True
293
+ # The cells whose own errors keep this one from planning (itself included).
294
+ blocked_by: list[str] = field(default_factory=list)
295
+ definition_key: str | None = None
296
+ normalized: str | None = None
297
+
298
+ def error(self, code: str, message: str, *, annotation: str | None = None, line=None):
299
+ self.diagnostics.append(
300
+ Diagnostic(code, Severity.ERROR, message, self.id, annotation, line)
301
+ )
302
+
303
+ def note(
304
+ self,
305
+ code: str,
306
+ severity: Severity,
307
+ message: str,
308
+ *,
309
+ annotation: str | None = None,
310
+ line=None,
311
+ ):
312
+ self.diagnostics.append(Diagnostic(code, severity, message, self.id, annotation, line))
313
+
314
+ @property
315
+ def has_error(self) -> bool:
316
+ return any(d.severity == Severity.ERROR for d in self.diagnostics)
317
+
318
+
319
+ def _is_canonical(value: Any) -> bool:
320
+ """None, bool, int, finite float, str, and lists and str-keyed dicts of these."""
321
+ if value is None or isinstance(value, bool | int | str):
322
+ return True
323
+ if isinstance(value, float):
324
+ return math.isfinite(value)
325
+ if isinstance(value, list):
326
+ return all(_is_canonical(item) for item in value)
327
+ if isinstance(value, dict):
328
+ return all(isinstance(k, str) and _is_canonical(v) for k, v in value.items())
329
+ return False
330
+
331
+
332
+ def _annotation_errors(exc: AnnotationError | ExceptionGroup) -> list[AnnotationError]:
333
+ """The errors a parser or validator raised: one, or an ``ExceptionGroup`` of them."""
334
+ if isinstance(exc, AnnotationError):
335
+ return [exc]
336
+ matched, rest = exc.split(AnnotationError)
337
+ if rest is not None:
338
+ raise rest
339
+ assert matched is not None
340
+ return _leaves(matched)
341
+
342
+
343
+ def _leaves(group: BaseExceptionGroup) -> list[AnnotationError]:
344
+ out: list[AnnotationError] = []
345
+ for exc in group.exceptions:
346
+ if isinstance(exc, BaseExceptionGroup):
347
+ out.extend(_leaves(exc))
348
+ elif isinstance(exc, AnnotationError):
349
+ out.append(exc)
350
+ return out
351
+
352
+
353
+ def _first_line(directives: list[Directive], key: str) -> int | None:
354
+ return next((d.line for d in directives if d.key == key), None)
355
+
356
+
357
+ def _top_level_imports(source: str) -> set[str]:
358
+ try:
359
+ tree = ast.parse(source)
360
+ except SyntaxError:
361
+ return set()
362
+ names: set[str] = set()
363
+ for node in tree.body:
364
+ if isinstance(node, ast.Import):
365
+ for alias in node.names:
366
+ names.add((alias.asname or alias.name).split(".")[0])
367
+ elif isinstance(node, ast.ImportFrom):
368
+ for alias in node.names:
369
+ names.add(alias.asname or alias.name)
370
+ return names
371
+
372
+
373
+ def _parse_phase(
374
+ cell: Cell, vocabulary: Vocabulary, analyzer: LanguageAnalyzer, strict: bool
375
+ ) -> tuple[CellAnalysis, Analyzed]:
376
+ """Phase 1: the body's analysis, and every annotation's parser."""
377
+ ca = CellAnalysis(id=cell.id, language=cell.language, source=cell.source)
378
+ ca.directives = vocabulary.grammar.directives(cell.source, keys=directive_keys(analyzer))
379
+
380
+ by_owner: dict[str, list[Directive]] = {}
381
+ for directive in ca.directives:
382
+ owner = vocabulary.owner(directive.key)
383
+ if owner is None:
384
+ close = difflib.get_close_matches(directive.key, vocabulary.keys, n=1, cutoff=0.75)
385
+ hint = f" Did you mean `@{close[0]}`?" if close else ""
386
+ ca.note(
387
+ "annotation_unknown",
388
+ Severity.ERROR if strict else Severity.WARN,
389
+ f"`@{directive.key}` is not an annotation this vocabulary knows.{hint}",
390
+ annotation=directive.key,
391
+ line=directive.line,
392
+ )
393
+ continue
394
+ by_owner.setdefault(owner.key, []).append(directive)
395
+
396
+ for annotation in vocabulary:
397
+ directives = by_owner.get(annotation.key)
398
+ if not directives:
399
+ continue
400
+ try:
401
+ value = annotation.parse(directives)
402
+ except (AnnotationError, ExceptionGroup) as raised:
403
+ for exc in _annotation_errors(raised):
404
+ ca.error(
405
+ exc.code or f"{directives[0].key}_unreadable",
406
+ str(exc),
407
+ annotation=annotation.key,
408
+ line=exc.line if exc.line is not None else directives[0].line,
409
+ )
410
+ continue
411
+ if isinstance(value, Partial):
412
+ for exc in value.errors:
413
+ ca.error(
414
+ exc.code or f"{directives[0].key}_unreadable",
415
+ str(exc),
416
+ annotation=annotation.key,
417
+ line=exc.line if exc.line is not None else directives[0].line,
418
+ )
419
+ value = value.value
420
+ ca.annotations[annotation.key] = value
421
+ if annotation.in_key:
422
+ encoded = annotation.encode(value) if annotation.encode is not None else value
423
+ if not _is_canonical(encoded):
424
+ ca.error(
425
+ f"{annotation.key}_unencodable",
426
+ f"`@{annotation.key}` has a value the key cannot encode "
427
+ f"({type(encoded).__name__}); the annotation needs an encoder",
428
+ annotation=annotation.key,
429
+ line=directives[0].line,
430
+ )
431
+ else:
432
+ ca.key_fields[annotation.key] = encoded
433
+ if annotation.effects is not None:
434
+ ca.effects.extend(annotation.effects(value))
435
+
436
+ if vocabulary.cell_effects is not None:
437
+ ca.effects.extend(vocabulary.cell_effects(cell.meta, dict(ca.annotations)))
438
+ for effect in ca.effects:
439
+ if not isinstance(effect, EFFECT_TYPES):
440
+ raise TypeError(f"{effect!r} is not one of the ten effects")
441
+
442
+ analyzed = analyzer.analyze(cell)
443
+ if analyzed.error is not None:
444
+ ca.error("syntax_error", analyzed.error)
445
+ ca.diagnostics.extend(replace(d, cell=ca.id) for d in analyzed.diagnostics)
446
+ return ca, analyzed
447
+
448
+
449
+ def _names_phase(ca: CellAnalysis, analyzed: Analyzed) -> None:
450
+ """Phase 2: defines, references and bound names after the effects."""
451
+ defines = list(analyzed.defines)
452
+ for effect in ca.effects:
453
+ if isinstance(effect, Defines):
454
+ for name in effect.names:
455
+ if name not in defines:
456
+ defines.append(name)
457
+
458
+ references = list(analyzed.references)
459
+ read: list[str] = []
460
+ for effect in ca.effects:
461
+ if isinstance(effect, Reads):
462
+ for name in effect.names:
463
+ read.append(name)
464
+ if name in references:
465
+ continue
466
+ if not effect.even_if_defined and name in defines:
467
+ continue
468
+ references.append(name)
469
+
470
+ bound: list[str] = []
471
+ for effect in ca.effects:
472
+ if isinstance(effect, Binds):
473
+ for name in effect.names:
474
+ if name in bound:
475
+ ca.error("bind_conflict", f"`{name}` is bound twice")
476
+ else:
477
+ bound.append(name)
478
+ for name in sorted(set(bound) & set(read)):
479
+ ca.error("bind_read_conflict", f"`{name}` is both bound and read from upstream")
480
+
481
+ ca.defines = defines
482
+ ca.references = [name for name in references if name not in bound]
483
+ ca.builtin_references = [name for name in analyzed.builtin_references if name not in bound]
484
+ ca.mutation_defines = list(analyzed.mutation_defines)
485
+ ca.bound = bound
486
+
487
+ for effect in ca.effects:
488
+ if isinstance(effect, After):
489
+ for cell_id in effect.cell_ids:
490
+ if cell_id not in ca.after:
491
+ ca.after.append(cell_id)
492
+ elif isinstance(effect, Seeds):
493
+ if effect.name in ca.seeds and ca.seeds[effect.name] != effect.cell_id:
494
+ ca.error("seed_conflict", f"`{effect.name}` is seeded from two cells")
495
+ else:
496
+ ca.seeds[effect.name] = effect.cell_id
497
+ if effect.name in bound:
498
+ ca.error("bind_read_conflict", f"`{effect.name}` is both bound and seeded")
499
+ elif isinstance(effect, Member):
500
+ if ca.member is not None and ca.member != effect:
501
+ ca.error("member_conflict", "the cell is a member of two variants")
502
+ ca.member = effect
503
+ elif isinstance(effect, FanOut):
504
+ if ca.fanout is not None and ca.fanout != effect:
505
+ ca.error("fanout_conflict", "the cell fans out over two groups")
506
+ ca.fanout = effect
507
+ elif isinstance(effect, Keeps):
508
+ ca.kept.extend(name for name in effect.names if name not in ca.kept)
509
+ elif isinstance(effect, Input):
510
+ if any((i.kind, i.name) == (effect.kind, effect.name) for i in ca.inputs):
511
+ ca.error(
512
+ "input_conflict", f"two {effect.kind} inputs have the address {effect.name!r}"
513
+ )
514
+ else:
515
+ ca.inputs.append(effect)
516
+ elif isinstance(effect, NoCache):
517
+ ca.nocache = True
518
+
519
+
520
+ def _resolve_groups(
521
+ cells: list[CellAnalysis], groups: GroupConfig
522
+ ) -> tuple[list[VariantGroupResolution], set[str]]:
523
+ """Phase 3: each variant group's members and its active one; collisions are errors."""
524
+ sweep = {g for g, m in groups.modes.items() if m == "sweep"}
525
+ grouped: dict[str, list[tuple[str, str]]] = {}
526
+ group_order: list[str] = []
527
+ by_id = {c.id: c for c in cells}
528
+ for cell in cells:
529
+ if cell.member is None:
530
+ continue
531
+ group, name = cell.member.group, cell.member.name
532
+ members = grouped.setdefault(group, [])
533
+ clash = next((cid for cid, n in members if n == name), None)
534
+ if clash is not None:
535
+ message = (
536
+ f"Variant name '{name}' is used by both cell {clash[:8]} and cell "
537
+ f"{cell.id[:8]} in group '{group}'"
538
+ )
539
+ for cid in (clash, cell.id):
540
+ by_id[cid].error("variant_collision", message, annotation="variant")
541
+ continue
542
+ if not members:
543
+ group_order.append(group)
544
+ members.append((cell.id, name))
545
+
546
+ resolutions: list[VariantGroupResolution] = []
547
+ inactive: set[str] = set()
548
+ for group in group_order:
549
+ members = grouped[group]
550
+ is_sweep = group in sweep
551
+ wanted = groups.active.get(group)
552
+ active_cell_id, active_name = members[0]
553
+ if wanted is not None and not is_sweep:
554
+ for cid, name in members:
555
+ if name == wanted:
556
+ active_cell_id, active_name = cid, name
557
+ break
558
+ resolutions.append(
559
+ VariantGroupResolution(
560
+ group=group,
561
+ active_name=active_name,
562
+ active_cell_id=active_cell_id,
563
+ members=list(members),
564
+ mode="sweep" if is_sweep else "switch",
565
+ )
566
+ )
567
+ if not is_sweep:
568
+ inactive.update(cid for cid, _ in members if cid != active_cell_id)
569
+ return resolutions, inactive
570
+
571
+
572
+ def _variant_notes(cells: list[CellAnalysis], dag: Dag, groups: GroupConfig) -> None:
573
+ """Strata's variant warnings: diverging defines, an unknown mode or active name."""
574
+ members = {res.group: res for res in dag.variant_groups}
575
+ by_id = {c.id: c for c in cells}
576
+ for cell in cells:
577
+ if cell.member is None or cell.member.group not in members:
578
+ continue
579
+ group = cell.member.group
580
+ line = _first_line(cell.directives, "variant")
581
+ siblings = [by_id[cid] for cid, _ in members[group].members if cid != cell.id]
582
+ own = set(cell.defines) - _top_level_imports(cell.source)
583
+ for sibling in siblings:
584
+ theirs = set(sibling.defines) - _top_level_imports(sibling.source)
585
+ if own != theirs:
586
+ cell.note(
587
+ "variant_contract_mismatch",
588
+ Severity.WARN,
589
+ f"variant `{cell.member.name}` of `{group}` defines {sorted(own)}, "
590
+ f"its sibling `{sibling.member.name if sibling.member else '?'}` "
591
+ f"defines {sorted(theirs)}",
592
+ annotation="variant",
593
+ line=line,
594
+ )
595
+ break
596
+ mode = groups.modes.get(group, "switch")
597
+ if mode not in ("switch", "sweep"):
598
+ cell.note(
599
+ "variant_mode_invalid",
600
+ Severity.WARN,
601
+ f"group `{group}` has mode {mode!r}; it runs as a switch",
602
+ annotation="variant",
603
+ line=line,
604
+ )
605
+ active = groups.active.get(group)
606
+ if mode == "sweep" and active:
607
+ cell.note(
608
+ "variant_active_redundant",
609
+ Severity.INFO,
610
+ f"group `{group}` is a sweep, so its active selection is ignored",
611
+ annotation="variant",
612
+ line=line,
613
+ )
614
+ if mode != "sweep" and active:
615
+ names = [name for _, name in members[group].members]
616
+ if active not in names:
617
+ cell.note(
618
+ "variant_active_unknown",
619
+ Severity.WARN,
620
+ f"group `{group}` selects `{active}`, which no cell provides",
621
+ annotation="variant",
622
+ line=line,
623
+ )
624
+
625
+
626
+ def _wiring_phase(cells: list[CellAnalysis], groups: GroupConfig) -> Dag:
627
+ """Phase 4: every reference to its latest earlier producer (``NotebookDag.from_cells``)."""
628
+ dag = Dag()
629
+ cell_ids = [c.id for c in cells]
630
+ dag.variant_groups, dag.inactive_cells = _resolve_groups(cells, groups)
631
+ inactive = dag.inactive_cells
632
+
633
+ cell_by_id = {c.id: c for c in cells}
634
+ sweep_producer_for_var: dict[str, SweepProducer] = {}
635
+ sweep_member_group: dict[str, str] = {}
636
+ for res in dag.variant_groups:
637
+ if res.mode != "sweep":
638
+ continue
639
+ for cid, _ in res.members:
640
+ sweep_member_group[cid] = res.group
641
+ var_members: dict[str, list[tuple[str, str]]] = {}
642
+ for cid, name in res.members:
643
+ for var in cell_by_id[cid].defines:
644
+ var_members.setdefault(var, []).append((name, cid))
645
+ for var, members in var_members.items():
646
+ sweep_producer_for_var[var] = SweepProducer(
647
+ group=res.group, variants=tuple(sorted(members))
648
+ )
649
+
650
+ sweep_group_variant_names: dict[str, tuple[str, ...]] = {
651
+ res.group: tuple(sorted(name for _, name in res.members))
652
+ for res in dag.variant_groups
653
+ if res.mode == "sweep"
654
+ }
655
+
656
+ for cell_id in cell_ids:
657
+ dag.cell_upstream[cell_id] = []
658
+ dag.cell_downstream[cell_id] = []
659
+ dag.consumed_variables[cell_id] = set()
660
+
661
+ # Wire each reference to the producer before this cell, then register this cell's
662
+ # defines: a mutating cell reads the prior value and produces the new one.
663
+ active_ids = set(cell_ids) - inactive
664
+ for cell in cells:
665
+ if cell.id in inactive:
666
+ continue
667
+ for var in (*cell.references, *cell.builtin_references):
668
+ if var in cell.seeds:
669
+ continue
670
+ producer = dag.variable_producer.get(var)
671
+ if producer is None or producer == cell.id:
672
+ continue
673
+ if isinstance(producer, SweepProducer):
674
+ if cell.id in {cid for _, cid in producer.variants}:
675
+ # A member refining its own group's variable does not read its siblings.
676
+ continue
677
+ dag.read_producers.setdefault(cell.id, {})[var] = producer
678
+ if isinstance(producer, SweepProducer):
679
+ for _name, member_id in producer.variants:
680
+ _wire(dag, member_id, cell.id, var)
681
+ else:
682
+ _wire(dag, producer, cell.id, var)
683
+
684
+ for name, seed_id in cell.seeds.items():
685
+ seed = cell_by_id.get(seed_id)
686
+ if seed is None:
687
+ cell.error("seed_unknown_cell", f"`{name}` is seeded from {seed_id!r}, no cell")
688
+ elif seed_id == cell.id:
689
+ cell.error("seed_unknown_cell", f"`{name}` is seeded from the cell itself")
690
+ elif seed_id in inactive:
691
+ cell.error(
692
+ "seed_unknown_cell", f"`{name}` is seeded from {seed_id}, an inactive variant"
693
+ )
694
+ elif name not in seed.defines:
695
+ cell.error(
696
+ "seed_unknown_cell",
697
+ f"`{name}` is seeded from {seed_id}, which does not define it",
698
+ )
699
+ else:
700
+ dag.read_producers.setdefault(cell.id, {})[name] = seed_id
701
+ _wire(dag, seed_id, cell.id, name)
702
+
703
+ dag.consumed_variables[cell.id].update(v for v in cell.kept if v in cell.defines)
704
+
705
+ for upstream_id in cell.after:
706
+ line = _first_line(cell.directives, "after")
707
+ if upstream_id == cell.id:
708
+ cell.note(
709
+ "after_self",
710
+ Severity.WARN,
711
+ "`@after` names this cell",
712
+ annotation="after",
713
+ line=line,
714
+ )
715
+ continue
716
+ if upstream_id not in cell_by_id:
717
+ cell.error(
718
+ "after_unknown_cell",
719
+ f"`@after {upstream_id}` names no cell",
720
+ annotation="after",
721
+ line=line,
722
+ )
723
+ continue
724
+ if upstream_id not in active_ids:
725
+ cell.error(
726
+ "after_inactive_variant",
727
+ f"`@after {upstream_id}` names a variant that is not active",
728
+ annotation="after",
729
+ line=line,
730
+ )
731
+ continue
732
+ dag.edges.append(DagEdge(from_cell_id=upstream_id, to_cell_id=cell.id, variable=""))
733
+ if upstream_id not in dag.cell_upstream[cell.id]:
734
+ dag.cell_upstream[cell.id].append(upstream_id)
735
+ if cell.id not in dag.cell_downstream[upstream_id]:
736
+ dag.cell_downstream[upstream_id].append(cell.id)
737
+
738
+ is_sweep_member = cell.id in sweep_member_group
739
+ fanout_group: str | None = None
740
+ if cell.fanout is not None:
741
+ read_groups = dag.sweep_groups_read(cell.id)
742
+ named = cell.fanout.group
743
+ if named is not None:
744
+ fanout_group = named if named in read_groups else None
745
+ else:
746
+ fanout_group = next(iter(read_groups)) if len(read_groups) == 1 else None
747
+ _fanout_notes(cell, read_groups, sweep_group_variant_names)
748
+ if fanout_group is not None and not is_sweep_member:
749
+ dag.fanout_cells[cell.id] = SweepProducer(
750
+ group=fanout_group,
751
+ variants=tuple(
752
+ sorted((name, cell.id) for name in sweep_group_variant_names[fanout_group])
753
+ ),
754
+ fanout_cell=cell.id,
755
+ )
756
+ for var in cell.defines:
757
+ if is_sweep_member and var in sweep_producer_for_var:
758
+ new_producer: str | SweepProducer = sweep_producer_for_var[var]
759
+ elif cell.id in dag.fanout_cells:
760
+ new_producer = dag.fanout_cells[cell.id]
761
+ else:
762
+ new_producer = cell.id
763
+ previous = dag.variable_producer.get(var)
764
+ if previous is not None and previous != new_producer:
765
+ short = (previous.group if isinstance(previous, SweepProducer) else previous)[:8]
766
+ dag.shadow_warnings.setdefault(cell.id, []).append(
767
+ f"Variable '{var}' shadows definition from cell {short}"
768
+ )
769
+ dag.variable_producer[var] = new_producer
770
+
771
+ active_cell_ids = [cid for cid in cell_ids if cid not in inactive]
772
+ for cell_id in active_cell_ids:
773
+ if not dag.cell_downstream[cell_id]:
774
+ dag.leaves.add(cell_id)
775
+ if not dag.cell_upstream[cell_id]:
776
+ dag.roots.add(cell_id)
777
+
778
+ on_cycle = _on_cycles(dag, active_cell_ids)
779
+ if on_cycle:
780
+ cycles = _cycles(dag, active_cell_ids)
781
+ message = "Cycle detected in DAG: " + (" → ".join(cycles[0]) if cycles else "unknown")
782
+ for cid in active_cell_ids:
783
+ if cid in on_cycle:
784
+ cell_by_id[cid].error("cycle", message)
785
+ dag.topological_order = _kahn(dag, active_cell_ids)
786
+ return dag
787
+
788
+
789
+ def _fanout_notes(
790
+ cell: CellAnalysis, read_groups: set[str], sizes: Mapping[str, tuple[str, ...]]
791
+ ) -> None:
792
+ """Strata's ``@per_variant`` warnings, against the sweep groups the cell reads."""
793
+ assert cell.fanout is not None
794
+ line = _first_line(cell.directives, "per_variant")
795
+ if cell.member is not None:
796
+ cell.note(
797
+ "per_variant_on_variant_member",
798
+ Severity.WARN,
799
+ "a variant member cannot also fan out over a group",
800
+ annotation="per_variant",
801
+ line=line,
802
+ )
803
+ return
804
+ named = cell.fanout.group
805
+ if named is not None:
806
+ if named not in read_groups:
807
+ cell.note(
808
+ "per_variant_unknown_group",
809
+ Severity.WARN,
810
+ f"`@per_variant {named}` names no sweep group the cell reads",
811
+ annotation="per_variant",
812
+ line=line,
813
+ )
814
+ return
815
+ effective = named
816
+ else:
817
+ if not read_groups:
818
+ cell.note(
819
+ "per_variant_no_sweep_source",
820
+ Severity.WARN,
821
+ "`@per_variant` reads no sweep-produced variable",
822
+ annotation="per_variant",
823
+ line=line,
824
+ )
825
+ return
826
+ if len(read_groups) > 1:
827
+ cell.note(
828
+ "per_variant_ambiguous_group",
829
+ Severity.WARN,
830
+ f"`@per_variant` reads {', '.join(sorted(read_groups))}; name one",
831
+ annotation="per_variant",
832
+ line=line,
833
+ )
834
+ return
835
+ effective = next(iter(read_groups))
836
+ if len(sizes.get(effective, ())) == 1:
837
+ cell.note(
838
+ "per_variant_group_of_one",
839
+ Severity.INFO,
840
+ f"group `{effective}` has one variant",
841
+ annotation="per_variant",
842
+ line=line,
843
+ )
844
+
845
+
846
+ def _restrict(graph: Dag, keep: set[str]) -> Dag:
847
+ """*graph* over the cells in *keep* only."""
848
+ ids = [cid for cid in graph.cell_upstream if cid in keep]
849
+ dag = Dag(
850
+ edges=[e for e in graph.edges if e.from_cell_id in keep and e.to_cell_id in keep],
851
+ cell_upstream={cid: [u for u in graph.cell_upstream[cid] if u in keep] for cid in ids},
852
+ cell_downstream={cid: [d for d in graph.cell_downstream[cid] if d in keep] for cid in ids},
853
+ variable_producer=dict(graph.variable_producer),
854
+ consumed_variables={cid: set(graph.consumed_variables[cid]) for cid in ids},
855
+ shadow_warnings={c: w for c, w in graph.shadow_warnings.items() if c in keep},
856
+ variant_groups=list(graph.variant_groups),
857
+ inactive_cells=set(graph.inactive_cells),
858
+ wired_inputs={
859
+ c: {p: set(v) for p, v in ins.items() if p in keep}
860
+ for c, ins in graph.wired_inputs.items()
861
+ if c in keep
862
+ },
863
+ fanout_cells={c: p for c, p in graph.fanout_cells.items() if c in keep},
864
+ read_producers={c: dict(r) for c, r in graph.read_producers.items() if c in keep},
865
+ )
866
+ active = [cid for cid in ids if cid not in graph.inactive_cells]
867
+ dag.leaves = {cid for cid in active if not dag.cell_downstream[cid]}
868
+ dag.roots = {cid for cid in active if not dag.cell_upstream[cid]}
869
+ dag.topological_order = _kahn(dag, active)
870
+ return dag
871
+
872
+
873
+ # --- the result -------------------------------------------------------------------
874
+
875
+
876
+ @dataclass
877
+ class Analysis:
878
+ """What ``analyze`` returns."""
879
+
880
+ cells: dict[str, CellAnalysis]
881
+ graph: Dag
882
+ dag: Dag
883
+ vocabulary: Vocabulary
884
+ analyzers: dict[str, LanguageAnalyzer]
885
+ groups: GroupConfig
886
+ normalization: str
887
+ digest: str
888
+
889
+ @property
890
+ def diagnostics(self) -> list[Diagnostic]:
891
+ return [d for cell in self.cells.values() for d in cell.diagnostics]
892
+
893
+ @property
894
+ def structural_errors(self) -> list[Diagnostic]:
895
+ return [d for d in self.diagnostics if d.code in STRUCTURAL_CODES]
896
+
897
+ def blocking(self, cell_id: str) -> list[Diagnostic]:
898
+ """The error diagnostics that keep *cell_id* from planning."""
899
+ return [
900
+ d
901
+ for blocker in self.cells[cell_id].blocked_by
902
+ for d in self.cells[blocker].diagnostics
903
+ if d.severity == Severity.ERROR
904
+ ]
905
+
906
+ def execution_key(self, cell_id: str, **kwargs: Any) -> Any:
907
+ """See ``cellgraph.keys.execution_key``."""
908
+ from cellgraph.keys import execution_key
909
+
910
+ return execution_key(self, cell_id, **kwargs)
911
+
912
+ def plan(self, target: str, ready: Callable[[str], bool]) -> list[str]:
913
+ """See ``cellgraph.plan.plan``."""
914
+ from cellgraph.plan import plan
915
+
916
+ return plan(self, target, ready)
917
+
918
+
919
+ def analyze(
920
+ cells: Iterable[Cell],
921
+ vocabulary: Vocabulary,
922
+ groups: GroupConfig | None = None,
923
+ normalization: str = NORMALIZE_LEGACY_1,
924
+ digest: str = DIGEST_LEGACY_1,
925
+ strict: bool = False,
926
+ *,
927
+ analyzers: Mapping[str, LanguageAnalyzer] | None = None,
928
+ context: Any = None,
929
+ ) -> Analysis:
930
+ """Analyze *cells*, given in source order.
931
+
932
+ ``analyzers`` maps a language to its analyzer; the registry when omitted.
933
+ ``context`` is passed to every validator as ``ValidationContext.host``. With
934
+ ``strict=True`` an unknown directive key is an error rather than a warning.
935
+ """
936
+ from cellgraph.keys import definition_key
937
+
938
+ if normalization not in (NORMALIZE_LEGACY_1, NORMALIZE_CELLGRAPH_1):
939
+ raise ValueError(f"unknown normalization {normalization!r}")
940
+ if digest not in (DIGEST_LEGACY_1, DIGEST_CELLGRAPH_1):
941
+ raise ValueError(f"unknown digest {digest!r}")
942
+ table = dict(analyzers) if analyzers is not None else registered_analyzers()
943
+ groups = groups or GroupConfig()
944
+ cell_list = list(cells)
945
+
946
+ analyses: list[CellAnalysis] = []
947
+ for cell in cell_list:
948
+ if cell.language not in table:
949
+ from cellgraph.languages import UnknownLanguageError
950
+
951
+ raise UnknownLanguageError(f"no language analyzer for {cell.language!r}")
952
+ ca, analyzed = _parse_phase(cell, vocabulary, table[cell.language], strict)
953
+ _names_phase(ca, analyzed)
954
+ analyses.append(ca)
955
+
956
+ for cell, ca in zip(cell_list, analyses, strict=True):
957
+ for annotation in vocabulary:
958
+ if annotation.validate is None or annotation.key not in ca.annotations:
959
+ continue
960
+ context_view = ValidationContext(
961
+ cell=cell, defines=tuple(ca.defines), references=tuple(ca.references), host=context
962
+ )
963
+ try:
964
+ ca.diagnostics.extend(
965
+ annotation.validate(ca.annotations[annotation.key], context_view)
966
+ )
967
+ except (AnnotationError, ExceptionGroup) as raised:
968
+ first = next((d.line for d in ca.directives if d.key in annotation.keys), None)
969
+ for exc in _annotation_errors(raised):
970
+ ca.error(
971
+ exc.code or f"{annotation.key}_invalid",
972
+ str(exc),
973
+ annotation=annotation.key,
974
+ line=exc.line if exc.line is not None else first,
975
+ )
976
+
977
+ graph = _wiring_phase(analyses, groups)
978
+ _variant_notes(analyses, graph, groups)
979
+
980
+ by_id = {ca.id: ca for ca in analyses}
981
+ for ca in analyses:
982
+ if ca.has_error:
983
+ ca.plannable = False
984
+ ca.blocked_by = [ca.id]
985
+ for blocker in [ca.id for ca in analyses if not ca.plannable]:
986
+ queue: deque[str] = deque(graph.cell_downstream.get(blocker, []))
987
+ seen: set[str] = set()
988
+ while queue:
989
+ current = queue.popleft()
990
+ if current in seen:
991
+ continue
992
+ seen.add(current)
993
+ target = by_id[current]
994
+ target.plannable = False
995
+ if blocker not in target.blocked_by:
996
+ target.blocked_by.append(blocker)
997
+ queue.extend(graph.cell_downstream.get(current, []))
998
+
999
+ plannable = {ca.id for ca in analyses if ca.plannable}
1000
+ analysis = Analysis(
1001
+ cells=by_id,
1002
+ graph=graph,
1003
+ dag=_restrict(graph, plannable),
1004
+ vocabulary=vocabulary,
1005
+ analyzers=table,
1006
+ groups=groups,
1007
+ normalization=normalization,
1008
+ digest=digest,
1009
+ )
1010
+ for ca in analyses:
1011
+ if ca.plannable:
1012
+ ca.normalized, ca.definition_key = definition_key(analysis, ca)
1013
+ return analysis