ruleflow 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.
core/__init__.py ADDED
File without changes
core/engine.py ADDED
@@ -0,0 +1,527 @@
1
+ from typing import Any, Sequence, MutableSequence, NamedTuple, Iterator, cast, Self
2
+ from abc import ABC, abstractmethod
3
+ from dataclasses import dataclass
4
+ from copy import copy
5
+ from core.signals import Signal
6
+
7
+
8
+ # ==== engine ====
9
+ @dataclass(slots=True) # we use slots to get C-like mutable struct behavior (NamedTuple is similar but immutable)
10
+ class Cell:
11
+ """A single (technically) mutable unit within a universe/string (a.k.a. Quanta). However, it is usually treated as immutable using copy() and hash().
12
+ A cell is analogous to a discrete spacial-unit and quanta is the matter that fills up that unit of space.
13
+ It is at this smallest unit of space that we care about causality.
14
+
15
+ Policies:
16
+ - The Cell class should not contain any fields other than the quanta and the metadata. This is so copies can be made easily.
17
+
18
+ Future Considerations:
19
+ - Add additional metadata/tags fields.
20
+ """
21
+ quanta: Any
22
+
23
+ # NOTE: the metadata is the ONLY thing that makes cells differentiable (other than quanta of course)
24
+ # Metadata regarding the creation and destruction of the cell... stored as indices to the events array.
25
+ created_at: int = 0 # this is the ONLY piece important metadata needed for a causality graph (can only be created one, so one event index)
26
+ destroyed_at: tuple[int, ...] = () # OPTIONAL metadata useful for analysis. Is an array of event indices (multiple indices for multiway systems)
27
+
28
+ def __str__(self):
29
+ """String representation of quanta"""
30
+ return str(self.quanta)
31
+
32
+ def __repr__(self):
33
+ return repr(self.quanta)
34
+
35
+ def __eq__(self, other: Cell):
36
+ """Semantic equality (use is for true equality)"""
37
+ return self.quanta == other.quanta
38
+
39
+ # noinspection PyDunderSlots,PyUnresolvedReferences
40
+ def __copy__(self) -> Cell:
41
+ n: Cell = object.__new__(self.__class__)
42
+ n.quanta = self.quanta
43
+ n.created_at = self.created_at
44
+ n.destroyed_at = self.destroyed_at
45
+ return n
46
+
47
+ def __deepcopy__(self, memo) -> Cell:
48
+ return self.__copy__() # force normal copy() behavior
49
+
50
+ def __hash__(self): # implemented to make Cell hashable (so can be used as keys in dict for instance)
51
+ return hash(self.quanta)
52
+
53
+
54
+ class SpaceState(ABC):
55
+ """Mutable container made up of `Cells` (a.k.a. Universe State of Space).
56
+
57
+ Policies:
58
+ - Should NOT be used as a simple container for Cells (in a replacement rule for instance), it should only be used for actual space states in events/time. Any other container should be in the form Sequence[Cell].
59
+ - All modifier methods must make sure to create new cells or cell copies if causality is to be tracked properly using the DeltaSets.
60
+ - All modifier methods (that create/destroy cells) should return DeltaCellSet containing the destroyed and created cells.
61
+ - All official SpaceStates must be created in this engine.py file. If one wants to create a 4D SpaceState, for instance, they must inherit from this, implement the methods, etc.
62
+ - All SpaceStates that inherit from this class must implement the modifier methods. If `find`, `len`, etc. are not sufficient helpers, additional helpers may be created here (if they are general enough), or in the subclasses ideally.
63
+ """
64
+
65
+ @abstractmethod
66
+ def __str__(self):
67
+ """String representation of SpaceState"""
68
+
69
+ @abstractmethod
70
+ def __repr__(self):
71
+ """Repr String representation of SpaceState"""
72
+
73
+ @abstractmethod
74
+ def __eq__(self, other: SpaceState) -> bool:
75
+ """Semantic equality (use `is` for true equality)"""
76
+
77
+ @abstractmethod
78
+ def __len__(self) -> int | Any:
79
+ """Should return the *size* of a container... whatever that may mean for N^1 or N^2 or N^3 spaces."""
80
+
81
+ @abstractmethod
82
+ def __bool__(self) -> bool:
83
+ """Should return the bool state of the space (has any contents)."""
84
+
85
+ @abstractmethod
86
+ def __hash__(self):
87
+ """Should make the SpaceState hashable so that it can be stored in a hash table."""
88
+
89
+ @abstractmethod
90
+ def __copy__(self) -> SpaceState | Any:
91
+ """Copies the SpaceState (self), but does not copy the cells (internal fields) themselves
92
+ (it only retains references to them). It is a shallow copy.
93
+ """
94
+
95
+ @abstractmethod
96
+ def __getitem__(self, item: int | slice) -> Cell | Sequence[Cell] | Any:
97
+ """Enables getting subspaces with slicing: space[0][1] of an N^2 space for instance."""
98
+
99
+ @abstractmethod
100
+ def get_all_cells(self) -> Sequence[Cell] | Iterator[Cell]:
101
+ """Returns all the cells that live in the SpaceState... regardless of the spaces dimensions.
102
+ This is useful for modifying all the cells in the SpaceState."""
103
+
104
+ @abstractmethod
105
+ def find(self, subspace: Cell | Sequence[Cell] | Any) -> Iterator[int | Any]:
106
+ """Find the `instances` number of occurrences of subspaces in the space (in any order desired) and return a
107
+ sequence of index positions or more complex positions. An empty set is returned if no matches are found.
108
+ If `instances` is -1, all subspaces should be matched.
109
+ Note that `instances` are useful for creating multi-way systems for example."""
110
+
111
+
112
+ class SpaceState1D(SpaceState):
113
+ """A SpaceState for a single dimensions (string) of space units (cells).
114
+
115
+ If sparse is set to True, a persistent data structure is used to share pointers between changes (can save a lot of memory)."""
116
+ __slots__ = 'cells',
117
+
118
+ def __init__(self, cells: MutableSequence[Cell]) -> None:
119
+ self.cells: MutableSequence[Cell] = cells
120
+
121
+ def __str__(self):
122
+ return ''.join((str(c) for c in self.cells))
123
+
124
+ def __repr__(self):
125
+ return str(self)
126
+
127
+ def __eq__(self, other: SpaceState1D) -> bool:
128
+ for sc, oc in zip(self.cells, other.cells):
129
+ if sc.quanta != oc.quanta:
130
+ return False
131
+ return True
132
+
133
+ def __len__(self) -> int:
134
+ return len(self.cells)
135
+
136
+ def __bool__(self) -> bool:
137
+ return bool(self.cells)
138
+
139
+ def __hash__(self):
140
+ return hash(tuple(self.cells))
141
+
142
+ def __copy__(self) -> SpaceState1D:
143
+ new_space: SpaceState1D = object.__new__(self.__class__) # create new object without using init
144
+ new_space.cells = copy(self.cells)
145
+ return new_space
146
+
147
+ def __getitem__(self, item: int | slice) -> Cell | Sequence[Cell]:
148
+ return self.cells[item]
149
+
150
+ def get_all_cells(self) -> Sequence[Cell]:
151
+ return self.cells
152
+
153
+ def find(self, subspace: Sequence[Cell]) -> Iterator[tuple[int, int]]:
154
+ subspace_len: int = len(subspace)
155
+ for i in range(len(self.cells) - subspace_len + 1): # we use left-to-right search
156
+ if all(self.cells[i + j] == subspace[j] for j in range(subspace_len) if subspace[j].quanta != '.'):
157
+ yield i, i + subspace_len
158
+
159
+ # ==== Custom Modifiers ====
160
+ def substitute(self, selector: tuple[int, int], new: Sequence[Cell]) -> DeltaCell:
161
+ start, end = selector
162
+ destroyed: tuple[Cell, ...] = tuple(self.cells[start:end])
163
+ self.cells[start:end] = new
164
+ return DeltaCell(destroyed, new)
165
+
166
+ def insert(self, selector: int, new: Sequence[Cell]) -> DeltaCell:
167
+ if selector < 0:
168
+ selector = len(self.cells) + selector + 1
169
+ self.cells[selector:selector] = new
170
+ return DeltaCell((), new)
171
+
172
+ def overwrite(self, selector: int, new: Sequence[Cell]) -> DeltaCell:
173
+ destroyed: tuple[Cell, ...] = ()
174
+ new_: tuple[Cell, ...] = () # only here due to "_" being a cursor jump/skip operator
175
+ if selector < 0:
176
+ selector = len(self.cells) + selector
177
+ for i in range(len(new)):
178
+ idx = selector + i
179
+ new_char: Cell = new[i]
180
+ if new_char.quanta == '_': # skip these
181
+ continue
182
+ try:
183
+ destroyed += (self.cells[idx],)
184
+ self.cells[idx] = new_char
185
+ except IndexError:
186
+ self.cells.append(new_char)
187
+ new_ += (new_char,)
188
+ return DeltaCell(destroyed, new_)
189
+
190
+ def delete(self, selector: tuple[int, int]) -> DeltaCell:
191
+ start, end = selector
192
+ destroyed: tuple[Cell, ...] = tuple(self.cells[start:end])
193
+ self.cells[start:end] = ()
194
+ return DeltaCell(destroyed, ())
195
+
196
+ def shift(self, selector: tuple[int, int], k: int) -> DeltaCell:
197
+ start, end = selector
198
+ if end < 0: end = len(self.cells) + end
199
+ if start < 0: start = len(self.cells) + start
200
+ if k == 0:
201
+ pass
202
+ elif k < 0:
203
+ k = abs(k)
204
+ self.cells[end:end] = self.cells[start - k:start] # insert "before" to "after"
205
+ self.cells[start - k:start] = () # delete before
206
+ else:
207
+ temp = self.cells[end:end + k] # delete "after" but remember it
208
+ self.cells[end:end + k] = ()
209
+ self.cells[start:start] = temp # insert "after" to "before"
210
+ return DeltaCell((), ())
211
+
212
+ def swap(self, selector1: tuple[int, int], selector2: tuple[int, int]) -> DeltaCell:
213
+ start1, end1 = selector1
214
+ if end1 < 0: end1 = len(self.cells) + end1
215
+ if start1 < 0: start1 = len(self.cells) + start1
216
+ start2, end2 = selector2
217
+ if end2 < 0: end2 = len(self.cells) + end2
218
+ if start2 < 0: start2 = len(self.cells) + start2
219
+ if (start1 < start2 < end1 or start1 < end2 < end1
220
+ or start2 < start1 < end2 or start2 < end1 < end2): # we do additional checks to ensure that huge slices are still caught.
221
+ raise IndexError('The selector indices cannot overlap!')
222
+ if start2 < start1:
223
+ start1, start2 = start2, start1
224
+ end1, end2 = end2, end1
225
+ temp1 = self.cells[start1:end1]
226
+ temp2 = self.cells[start2:end2]
227
+ self.cells[start2:end2] = temp1
228
+ self.cells[start1:end1] = temp2
229
+ return DeltaCell((), ())
230
+
231
+ def reverse(self, selector: tuple[int, int]) -> DeltaCell:
232
+ start, end = selector
233
+ self.cells[start:end] = self.cells[start:end][::-1]
234
+ return DeltaCell((), ())
235
+
236
+
237
+ class SpaceState2D(SpaceState):
238
+ """It is here that we implement the 2D SpaceState. Just a placeholder for now."""
239
+ pass
240
+
241
+
242
+ class SpaceStateGraph(SpaceState):
243
+ """It is here that we implement the graph SpaceState. Just a placeholder for now."""
244
+ pass
245
+
246
+
247
+ class RuleMatch(NamedTuple):
248
+ """An object that represents a rule match. This is returned by Rule.match() and passed to Rule.apply()."""
249
+ space: SpaceState
250
+ matches: Sequence[tuple[int, int]] | Any # Any is to support higher dimension matches.
251
+ conflicts: set[int] # conflicting matches (idx of the match) that must be resolved.
252
+ metadata: Any = None # optional metadata
253
+
254
+
255
+ class Rule(ABC):
256
+ def __init__(self):
257
+ """Should take arguments that define the rule behavior. For instance, ``SubstitutionRule(match: string, replace: string)`` should be for a rule that finds a matching substring and replaces it.
258
+ ``InsertionRule(insert: string, at_idx: string)`` should be a rule that inserts a string at the specified index. Whatever the init arguments are, they must be created as fields internally in an elegant format.
259
+
260
+ The Rule should be responsible for duplicating (or not) the SpaceState(s) when applying itself. This way,
261
+ multi-way systems are supported because the Rule can apply multiple different modifications to multiple
262
+ different SpaceStates if necessary.
263
+
264
+ Note that all the code is assuming that multi-way systems take place for multiple modifications. However, if we want to modify a SpaceState, without creating branches, we must do that in the Rule itself (i.e. having entire "rulesets" within rules).
265
+ """
266
+ # metadata
267
+ self.id: str = '' # could be used to filter rules.
268
+
269
+ # Flags (these are only those which modify default RuleSet behavior)
270
+ self.disabled: bool = False # if the rule is disabled (dead)
271
+ self.group: int | str = 0 # group together rules this way.
272
+ self.group_break: bool = True # break out of the group upon successful application of rule.
273
+ self.always_apply: bool = False # always apply this rule no matter what (disregards grouping)
274
+ # NOTE: any and all additional flags that modify internal rule behavior MUST (for the sake of clarity) be in the implementation of the rule.
275
+
276
+ @abstractmethod
277
+ def match(self, spaces: Sequence[SpaceState]) -> Sequence[RuleMatch]:
278
+ pass
279
+
280
+ @abstractmethod
281
+ def apply(self, rule_matches: Sequence[RuleMatch]) -> Sequence[DeltaSpace]:
282
+ """Applies the rule to the given ``SpaceState(s)``. Modified SpaceStates are returned.
283
+ Important for implementation: *new/copied* SpaceState(s) must be created, modified, and returned.
284
+
285
+ Rule is responsible for taking all current states to provide maximum flexibility (so different rules can have different behavior: sessies + messies) (TRUST ME!!! I doubted my past self on this and then wasted a bunch of time... just keep it as-is you crazy future self!)
286
+ """
287
+ pass
288
+
289
+
290
+ class RuleSet:
291
+ """This contains the Rules that can be applied. Additional, more complex, behavior can be implemented by subclassing it.
292
+
293
+ Note that all the code is engineered around assuming multi-way systems for more than one rule being applied.
294
+ """
295
+
296
+ def __init__(self, rules: list[Rule]):
297
+ """This should be implemented by subclasses.
298
+ This should ideally accept a list of Rules either as objects or as strings that should then be parsed into their corresponding rules. The rules should be stored in array."""
299
+ self.rules: list[Rule] = rules
300
+
301
+ def __str__(self) -> str:
302
+ return str(self.rules)
303
+
304
+ def __repr__(self) -> str:
305
+ return str(self)
306
+
307
+ def apply(self, to_spaces: Sequence[SpaceState]) -> list[DeltaSpaces]:
308
+ """Applies the Rules to the given spaces, and returns a sequence of the DeltaSpaceSet."""
309
+ group_management: dict = {
310
+ # group IDs go here along with whether they are active - id: bool
311
+ }
312
+ applied_rules: list[DeltaSpaces] = []
313
+ for rule in self.rules:
314
+ if rule.disabled:
315
+ continue
316
+ active: bool = group_management.setdefault(rule.group, True)
317
+ if not active and not rule.always_apply:
318
+ continue
319
+ rule_matches: Sequence[RuleMatch] = rule.match(to_spaces)
320
+ if rule_matches: # if there are any rule matches.
321
+ space_deltas: DeltaSpaces = DeltaSpaces(rule.apply(rule_matches), rule)
322
+ if space_deltas: # to be robust in case a complex rule still fails (even though input matches were found we can't guarantee that it will always work)
323
+ applied_rules.append(space_deltas)
324
+ if rule.group_break: group_management[rule.group] = False
325
+ return applied_rules
326
+
327
+
328
+ class DeltaCell(NamedTuple): # the cells that were created and destroyed by some SpaceState.modifier() method.
329
+ destroyed_cells: Sequence[Cell]
330
+ new_cells: Sequence[Cell]
331
+
332
+ def __bool__(self) -> bool:
333
+ return bool(self.destroyed_cells) or bool(self.new_cells) # if any changes occurred, return true.
334
+
335
+
336
+ class DeltaSpace(NamedTuple): # returned by Rule.apply() in a Sequence[DeltaSpace]
337
+ """Single application of a rule within Rule.apply()."""
338
+ input_space: SpaceState # we always have this filled so that we know what spaces had what changes (if any) made
339
+ output_space: Sequence[SpaceState | None] # can include many children branches
340
+ cell_deltas: Sequence[DeltaCell] # should be aligned with output_space array (so branches align)
341
+
342
+ def __bool__(self) -> bool:
343
+ return any(self.output_space) or any(self.cell_deltas) # we check both to be as robust as possible... what if a rule does not return delta cells due to modifying but not adding or deleting?
344
+
345
+
346
+ class DeltaSpaces(NamedTuple): # returned by RuleSet.apply() in a Sequence[DeltaSpaces]
347
+ """All delta spaces that happened under a given rule."""
348
+ space_deltas: Sequence[DeltaSpace]
349
+ rule: Rule | None
350
+
351
+ def __bool__(self) -> bool:
352
+ return any(self.space_deltas) # if any changes were recorded.
353
+
354
+
355
+ @dataclass(slots=True)
356
+ class Event:
357
+ time: int # also known as time - should be unique to every event
358
+ space_deltas: list[DeltaSpaces] # all space deltas (organized by the rules they were applied under)
359
+
360
+ # metadata
361
+ inert: bool = False # if true, the new event caused no changes to the system.
362
+ weight: int | float = 1 # could be used for weighted causality tracking. (think of it as a time multiplier/dilator)
363
+ causal_distance_to_creation: int = 0 # minimum distance (min number of nodes) to the creation event node.
364
+
365
+ @property # maybe cache this?
366
+ def affected_cells(self) -> Iterator[DeltaCell]:
367
+ """Returns all cell deltas"""
368
+ for r in self.space_deltas:
369
+ for space_delta in r.space_deltas:
370
+ for cell_delta in space_delta.cell_deltas:
371
+ if cell_delta:
372
+ yield cell_delta
373
+
374
+ @property # maybe cache this?
375
+ def causally_connected_events(self) -> Iterator[int]:
376
+ """Returns events (stored as indices) whose created cells were destroyed by this event"""
377
+ for delta in self.affected_cells:
378
+ for cell in delta.destroyed_cells:
379
+ yield cell.created_at
380
+
381
+ @property # maybe cache this?
382
+ def spaces(self) -> Iterator[SpaceState]:
383
+ """Returns all newly created spaces"""
384
+ for r in self.space_deltas:
385
+ for space_delta in r.space_deltas:
386
+ for space in space_delta.output_space:
387
+ if space is not None:
388
+ yield space
389
+
390
+ @property # maybe cache this?
391
+ def spaces_with_metadata(self) -> Iterator[tuple[DeltaSpaces, DeltaSpace, SpaceState]]:
392
+ """Returns all newly created spaces along with their metadata (in the parent structure)"""
393
+ for r in self.space_deltas:
394
+ for space_delta in r.space_deltas:
395
+ for space in space_delta.output_space:
396
+ if space is not None:
397
+ yield r, space_delta, space
398
+
399
+ def __str__(self):
400
+ return '[' + ', '.join(str(space) for space in self.spaces) + ']' # TODO remove this to a dedication printer
401
+
402
+
403
+ class Flow:
404
+ """The base class for a rule flow, additional behavior should be implemented by subclassing this class."""
405
+
406
+ # Signals (can be used to live update analysis objects like the causal graph)
407
+ on_evolved_step: Signal[Self] = Signal()
408
+ on_evolved_n: Signal[Self, int] = Signal() # after all evolves
409
+ on_undone_step: Signal[Self] = Signal()
410
+ on_undone_n: Signal[Self, int] = Signal() # after all undo's
411
+ on_clear: Signal[Self] = Signal()
412
+ on_ruleset_set: Signal[Self] = Signal()
413
+
414
+ def __init__(self):
415
+ self.ruleset: RuleSet = RuleSet([]) # can be changed at any time to provide a new set of rules.
416
+ self.events: list[Event] = [] # defaults to empty... but nothing will work properly
417
+
418
+ # progress tracking attributes
419
+ self.n_step_progress: float = 0 # percentage of steps run by some_method_n().
420
+
421
+ def set_ruleset(self, ruleset: RuleSet) -> None:
422
+ """Used to set the rule set"""
423
+ self.ruleset: RuleSet = ruleset
424
+ self.on_ruleset_set.emit(self)
425
+
426
+ def set_initial_space(self, initial_space: Sequence[SpaceState]) -> None:
427
+ """Used to set the initial space"""
428
+ if not self.events:
429
+ self.events.append(cast(Event, cast(object, 0)))
430
+ self.events[0] = Event(0, [DeltaSpaces(tuple((DeltaSpace(i, (i,), (DeltaCell((), ()),)) for i in initial_space)), None)]) # initial output space must be `i` as well so that next evolve() works.
431
+ for i in initial_space:
432
+ for cell in i.get_all_cells():
433
+ cell.created_at = 0
434
+
435
+ def clear_evolution(self) -> None:
436
+ """Clear the evolution."""
437
+ del self.events[1:]
438
+ self.on_clear.emit(self)
439
+
440
+ @property
441
+ def current_event(self) -> Event:
442
+ return self.events[-1]
443
+
444
+ @property
445
+ def current_event_idx(self) -> int:
446
+ return len(self.events) - 1
447
+
448
+ def _evolve(self) -> None:
449
+ """ Evolve the system by one step.
450
+
451
+ This can be reimplemented by subclasses to modify behavior. As it stands, it does the following:
452
+ - apply the rules to the current space states using RuleSet.apply()
453
+ - if a rule was successfully applied, create a new event and increment the time ``step``
454
+ - Update event and cell metadata (important for tracking causality)
455
+ - set the applied rules (the applied rules are associated with the space states they modified)
456
+ - extract all the modified space states from the applied rules and add them to the space states of the Event.
457
+ """
458
+ applied_rules: list[DeltaSpaces] = self.ruleset.apply(to_spaces=tuple(self.current_event.spaces))
459
+ if not any(applied_rules): # if no rules made any modifications to the spaces
460
+ self.current_event.inert = True
461
+ return
462
+
463
+ # Create a new event and process it
464
+ self.events.append(
465
+ Event(self.current_event.time + 1, space_deltas=applied_rules) # create a new event
466
+ )
467
+
468
+ # process causality
469
+ current_event_idx: int = self.current_event_idx
470
+ for ar in applied_rules:
471
+ for sd in ar.space_deltas:
472
+ for dc in sd.cell_deltas:
473
+ for cell in dc.new_cells:
474
+ cell.created_at = current_event_idx
475
+ for cell in dc.destroyed_cells:
476
+ cell.destroyed_at += (current_event_idx,) # first one, of course, will be the main lineage
477
+
478
+ # process causal distance to creation
479
+ min_prev: int = min((self.events[e_idx].causal_distance_to_creation
480
+ for e_idx in self.current_event.causally_connected_events),
481
+ default=-1)
482
+ self.current_event.causal_distance_to_creation = min_prev + 1
483
+
484
+ # emit any signals
485
+ self.on_evolved_step.emit(self)
486
+
487
+ def evolve(self, n_steps: int, break_when_inert: bool = False) -> None:
488
+ """Evolve the system n steps."""
489
+ i: int = 0
490
+ while i < n_steps:
491
+ # print(str(next(self.current_event.spaces).cells.search_buffer).replace('A', '\x1b[1;41m A \x1b[0m').replace('B', '\x1b[1;42m B \x1b[0m')) # if we want to see how the buffer changes.
492
+ self.n_step_progress = (i + 1) / n_steps
493
+ i += 1
494
+ self._evolve()
495
+ if break_when_inert and self.current_event.inert:
496
+ break
497
+
498
+ # emit any signals
499
+ self.on_evolved_n.emit(self, n_steps)
500
+
501
+ def _undo(self) -> None:
502
+ """undo the last event..."""
503
+ if self.current_event_idx == 0:
504
+ return
505
+ for ar in self.current_event.space_deltas:
506
+ for sd in ar.space_deltas:
507
+ for dc in sd.cell_deltas:
508
+ for cell in dc.destroyed_cells:
509
+ cell.destroyed_at = tuple(i for i in cell.destroyed_at if i != self.current_event_idx)
510
+ self.events.pop()
511
+
512
+ # emit any signals
513
+ self.on_undone_step.emit(self)
514
+
515
+ def undo(self, n_steps: int) -> None:
516
+ for _ in range(n_steps):
517
+ self.n_step_progress = (_ + 1) / n_steps
518
+ self._undo()
519
+
520
+ self.on_undone_n.emit(self, n_steps)
521
+
522
+ def __str__(self) -> str:
523
+ return '\n'.join(str(e) for e in self.events)
524
+
525
+
526
+ if __name__ == '__main__':
527
+ pass
core/graph.py ADDED
@@ -0,0 +1,33 @@
1
+ """Provides all the tools for optimized and rigorous graph analysis.
2
+
3
+ FRAMEWORK NOTES:
4
+ - use pyvis Network to render interactive graphs.
5
+
6
+ TODO:
7
+ - Redesign to support live graph updating (to keep up to date with flow).
8
+ - Add more tools for seamless analysis and integrations.
9
+ """
10
+ from core.engine import Flow
11
+ from networkx import MultiDiGraph
12
+ from typing import Sequence, Self
13
+
14
+
15
+ class EventCausalityGraph(MultiDiGraph):
16
+ def build(self, flow: Flow,
17
+ event_range: tuple[int, int, int],
18
+ collapse_multi_edges: bool = False) -> Self:
19
+ # construct causal graph - because each node is literally the time, and thus index, it can be used to query to the actual event for more granular information.
20
+ connected_container: type[tuple] | type[set] = set if collapse_multi_edges else tuple
21
+ for event in flow.events[event_range[0]:event_range[1]+1:event_range[2]]:
22
+ causally_connected: Sequence[int] = connected_container(event.causally_connected_events)
23
+ self.add_node(
24
+ event.time,
25
+ # these get passed on to the nodes of VisJS network.
26
+ label=f'{event.time}',
27
+ title=f' Causal Distance: {event.causal_distance_to_creation}\n'
28
+ f'Connected Events: {len(causally_connected)}',
29
+ shape='box'
30
+ )
31
+ for parent_time in causally_connected:
32
+ self.add_edge(parent_time, event.time)
33
+ return self