dialectical-framework 0.4.4__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.
Files changed (84) hide show
  1. dialectical_framework/__init__.py +43 -0
  2. dialectical_framework/ai_dto/__init__.py +0 -0
  3. dialectical_framework/ai_dto/causal_cycle_assessment_dto.py +16 -0
  4. dialectical_framework/ai_dto/causal_cycle_dto.py +16 -0
  5. dialectical_framework/ai_dto/causal_cycles_deck_dto.py +11 -0
  6. dialectical_framework/ai_dto/dialectical_component_dto.py +16 -0
  7. dialectical_framework/ai_dto/dialectical_components_deck_dto.py +12 -0
  8. dialectical_framework/ai_dto/dto_mapper.py +103 -0
  9. dialectical_framework/ai_dto/reciprocal_solution_dto.py +24 -0
  10. dialectical_framework/analyst/__init__.py +1 -0
  11. dialectical_framework/analyst/audit/__init__.py +0 -0
  12. dialectical_framework/analyst/consultant.py +30 -0
  13. dialectical_framework/analyst/decorator_action_reflection.py +28 -0
  14. dialectical_framework/analyst/decorator_discrete_spiral.py +30 -0
  15. dialectical_framework/analyst/domain/__init__.py +0 -0
  16. dialectical_framework/analyst/domain/assessable_cycle.py +128 -0
  17. dialectical_framework/analyst/domain/cycle.py +133 -0
  18. dialectical_framework/analyst/domain/interpretation.py +16 -0
  19. dialectical_framework/analyst/domain/rationale.py +116 -0
  20. dialectical_framework/analyst/domain/spiral.py +47 -0
  21. dialectical_framework/analyst/domain/transformation.py +24 -0
  22. dialectical_framework/analyst/domain/transition.py +160 -0
  23. dialectical_framework/analyst/domain/transition_cell_to_cell.py +29 -0
  24. dialectical_framework/analyst/domain/transition_segment_to_segment.py +16 -0
  25. dialectical_framework/analyst/strategic_consultant.py +32 -0
  26. dialectical_framework/analyst/think_action_reflection.py +217 -0
  27. dialectical_framework/analyst/think_constructive_convergence.py +87 -0
  28. dialectical_framework/analyst/wheel_builder_transition_calculator.py +157 -0
  29. dialectical_framework/brain.py +81 -0
  30. dialectical_framework/dialectical_analysis.py +14 -0
  31. dialectical_framework/dialectical_component.py +111 -0
  32. dialectical_framework/dialectical_components_deck.py +48 -0
  33. dialectical_framework/dialectical_reasoning.py +105 -0
  34. dialectical_framework/directed_graph.py +419 -0
  35. dialectical_framework/enums/__init__.py +0 -0
  36. dialectical_framework/enums/causality_type.py +10 -0
  37. dialectical_framework/enums/di.py +11 -0
  38. dialectical_framework/enums/dialectical_reasoning_mode.py +9 -0
  39. dialectical_framework/enums/predicate.py +9 -0
  40. dialectical_framework/protocols/__init__.py +0 -0
  41. dialectical_framework/protocols/assessable.py +141 -0
  42. dialectical_framework/protocols/causality_sequencer.py +111 -0
  43. dialectical_framework/protocols/content_fidelity_evaluator.py +16 -0
  44. dialectical_framework/protocols/has_brain.py +18 -0
  45. dialectical_framework/protocols/has_config.py +18 -0
  46. dialectical_framework/protocols/ratable.py +79 -0
  47. dialectical_framework/protocols/reloadable.py +7 -0
  48. dialectical_framework/protocols/thesis_extractor.py +15 -0
  49. dialectical_framework/settings.py +77 -0
  50. dialectical_framework/synthesis.py +7 -0
  51. dialectical_framework/synthesist/__init__.py +1 -0
  52. dialectical_framework/synthesist/causality/__init__.py +0 -0
  53. dialectical_framework/synthesist/causality/causality_sequencer_balanced.py +398 -0
  54. dialectical_framework/synthesist/causality/causality_sequencer_desirable.py +55 -0
  55. dialectical_framework/synthesist/causality/causality_sequencer_feasible.py +55 -0
  56. dialectical_framework/synthesist/causality/causality_sequencer_realistic.py +56 -0
  57. dialectical_framework/synthesist/concepts/__init__.py +0 -0
  58. dialectical_framework/synthesist/concepts/thesis_extractor_basic.py +135 -0
  59. dialectical_framework/synthesist/polarity/__init__.py +0 -0
  60. dialectical_framework/synthesist/polarity/polarity_reasoner.py +700 -0
  61. dialectical_framework/synthesist/polarity/reason_blind.py +62 -0
  62. dialectical_framework/synthesist/polarity/reason_conversational.py +91 -0
  63. dialectical_framework/synthesist/polarity/reason_fast.py +177 -0
  64. dialectical_framework/synthesist/polarity/reason_fast_and_simple.py +52 -0
  65. dialectical_framework/synthesist/polarity/reason_fast_polarized_conflict.py +55 -0
  66. dialectical_framework/synthesist/reverse_engineer.py +401 -0
  67. dialectical_framework/synthesist/wheel_builder.py +337 -0
  68. dialectical_framework/utils/__init__.py +1 -0
  69. dialectical_framework/utils/dc_replace.py +42 -0
  70. dialectical_framework/utils/decompose_probability_uniformly.py +15 -0
  71. dialectical_framework/utils/extend_tpl.py +12 -0
  72. dialectical_framework/utils/gm.py +12 -0
  73. dialectical_framework/utils/is_async.py +13 -0
  74. dialectical_framework/utils/use_brain.py +80 -0
  75. dialectical_framework/validator/__init__.py +1 -0
  76. dialectical_framework/validator/basic_checks.py +75 -0
  77. dialectical_framework/validator/check.py +12 -0
  78. dialectical_framework/wheel.py +395 -0
  79. dialectical_framework/wheel_segment.py +203 -0
  80. dialectical_framework/wisdom_unit.py +185 -0
  81. dialectical_framework-0.4.4.dist-info/LICENSE +21 -0
  82. dialectical_framework-0.4.4.dist-info/METADATA +123 -0
  83. dialectical_framework-0.4.4.dist-info/RECORD +84 -0
  84. dialectical_framework-0.4.4.dist-info/WHEEL +4 -0
@@ -0,0 +1,419 @@
1
+ from typing import (Callable, Dict, Generic, Tuple, TypeVar, Union, overload)
2
+
3
+ from dialectical_framework.analyst.domain.transition import Transition
4
+ from dialectical_framework.analyst.domain.transition_cell_to_cell import \
5
+ TransitionCellToCell
6
+ from dialectical_framework.dialectical_component import DialecticalComponent
7
+ from dialectical_framework.enums.predicate import Predicate
8
+ from dialectical_framework.wheel_segment import WheelSegment
9
+
10
+ T = TypeVar("T", bound=Transition)
11
+
12
+ AliasInput = Union[str, DialecticalComponent, list[Union[str, DialecticalComponent]]]
13
+
14
+
15
+ def _extract_aliases(input_data: AliasInput) -> list[str]:
16
+ """Extract aliases from various input types."""
17
+ if isinstance(input_data, str):
18
+ return [input_data]
19
+ elif isinstance(input_data, DialecticalComponent):
20
+ return [input_data.alias]
21
+ elif isinstance(input_data, list):
22
+ aliases = []
23
+ for item in input_data:
24
+ if isinstance(item, str):
25
+ aliases.append(item)
26
+ elif isinstance(item, DialecticalComponent):
27
+ aliases.append(item.alias)
28
+ return aliases
29
+ else:
30
+ raise TypeError(f"Unsupported input type: {type(input_data)}")
31
+
32
+
33
+ class DirectedGraph(Generic[T]):
34
+ def __init__(self):
35
+ self._transitions: Dict[Tuple[frozenset[str], frozenset[str]], T] = {}
36
+
37
+ def add_transition(self, transition: T) -> None:
38
+ """Add a transition to the directed graph."""
39
+ key = (
40
+ frozenset(transition.source_aliases),
41
+ frozenset(transition.target_aliases),
42
+ )
43
+
44
+ # For TransitionOneToOne, ensure only one outgoing transition per source
45
+ if isinstance(transition, TransitionCellToCell):
46
+ for existing_key, existing_transition in self._transitions.items():
47
+ if existing_key[0] == key[0]: # Same source aliases
48
+ raise ValueError(
49
+ f"Multiple outgoing transitions for source aliases {transition.source_aliases} found. Only one is allowed."
50
+ )
51
+
52
+ self._transitions[key] = transition
53
+
54
+ def get_transition(
55
+ self, source_aliases: AliasInput, target_aliases: AliasInput
56
+ ) -> T | None:
57
+ """Retrieve a transition by source and target aliases."""
58
+ source_aliases_list = _extract_aliases(source_aliases)
59
+ target_aliases_list = _extract_aliases(target_aliases)
60
+ key = (frozenset(source_aliases_list), frozenset(target_aliases_list))
61
+ return self._transitions.get(key)
62
+
63
+ def get_transitions_from_source(self, source_aliases: AliasInput) -> list[T]:
64
+ """Get all transitions that have the given source aliases."""
65
+ source_aliases_list = _extract_aliases(source_aliases)
66
+ source_set = frozenset(source_aliases_list)
67
+ return [
68
+ transition
69
+ for (src_set, _), transition in self._transitions.items()
70
+ if src_set == source_set
71
+ ]
72
+
73
+ def get_first_transition_from_source(self, source_aliases: AliasInput) -> T | None:
74
+ """Get the first transition that has the given source aliases."""
75
+ transitions = self.get_transitions_from_source(source_aliases)
76
+ return transitions[0] if transitions else None
77
+
78
+ def get_all_transitions(self) -> list[T]:
79
+ """
80
+ Get all transitions in the graph. Be mindful that these transitions might not be forming paths.
81
+ So rather consider using the "traverse_dfs_with_paths" or "first_path" method.
82
+ """
83
+ return list(self._transitions.values())
84
+
85
+ def get_transition_count(self) -> int:
86
+ """Get the number of transitions in the graph."""
87
+ return len(self._transitions)
88
+
89
+ def is_empty(self) -> bool:
90
+ """Check if the graph has no transitions."""
91
+ return len(self._transitions) == 0
92
+
93
+ def find_outbound_source_aliases(self, start: WheelSegment) -> list[list[str]]:
94
+ outbound_source_aliases = []
95
+ start_possible_components = {
96
+ comp.alias for comp in [start.t, start.t_plus, start.t_minus] if comp
97
+ }
98
+ for (_, _), transition in self._transitions.items():
99
+ transition_source = transition.source
100
+ if isinstance(transition_source, DialecticalComponent):
101
+ transition_source_components = {transition_source.alias}
102
+ else: # WheelSegment
103
+ transition_source_components = {
104
+ comp.alias
105
+ for comp in [
106
+ transition_source.t,
107
+ transition_source.t_plus,
108
+ transition_source.t_minus,
109
+ ]
110
+ if comp
111
+ }
112
+
113
+ if start_possible_components.issubset(transition_source_components):
114
+ outbound_source_aliases.append(transition.source_aliases)
115
+
116
+ return outbound_source_aliases
117
+
118
+ @overload
119
+ def remove_transition(
120
+ self, source_aliases: AliasInput, target_aliases: AliasInput
121
+ ) -> bool:
122
+ """Remove a transition by source and target aliases. Returns True if removed, False if not found."""
123
+ ...
124
+
125
+ @overload
126
+ def remove_transition(self, transition: T) -> bool:
127
+ """Remove a transition by Transition object."""
128
+ ...
129
+
130
+ def remove_transition(
131
+ self, source_aliases_or_transition, target_aliases=None
132
+ ) -> bool:
133
+ """Remove a transition by source and target aliases or by Transition object."""
134
+ if hasattr(source_aliases_or_transition, "source_aliases") and hasattr(
135
+ source_aliases_or_transition, "target_aliases"
136
+ ):
137
+ # Called with Transition object
138
+ transition = source_aliases_or_transition
139
+ key = (
140
+ frozenset(transition.source_aliases),
141
+ frozenset(transition.target_aliases),
142
+ )
143
+ else:
144
+ # Called with source_aliases and target_aliases
145
+ source_aliases_list = _extract_aliases(source_aliases_or_transition)
146
+ target_aliases_list = _extract_aliases(target_aliases)
147
+ key = (frozenset(source_aliases_list), frozenset(target_aliases_list))
148
+
149
+ if key in self._transitions:
150
+ del self._transitions[key]
151
+ return True
152
+ return False
153
+
154
+ def clear(self) -> None:
155
+ """Remove all transitions from the graph."""
156
+ self._transitions.clear()
157
+
158
+ def traverse_dfs_with_paths(
159
+ self,
160
+ start_aliases: AliasInput | None = None,
161
+ visit_callback: Callable[[list[T], bool], None] | None = None,
162
+ predicate_filter: Predicate | None = None,
163
+ ) -> list[list[T]]:
164
+ """
165
+ Traverse all possible paths in the graph, detecting circles per path.
166
+ Returns all complete paths found.
167
+
168
+ Args:
169
+ start_aliases: Starting aliases for traversal
170
+ visit_callback: Optional callback function called for each path
171
+ predicate_filter: If provided, only traverse transitions with this predicate type
172
+ """
173
+ all_paths = []
174
+
175
+ def _can_connect_constructively(
176
+ target_aliases: list[str], next_source_aliases: list[str]
177
+ ) -> bool:
178
+ """
179
+ For constructively_converges_to, check if segments can connect at the segment level.
180
+ E.g., [T1-, T1] -> [T2+] can connect to [T2-, T2] -> [T3+] because T2 is shared.
181
+ """
182
+ # Extract base aliases (remove +/- suffixes)
183
+ target_bases = {alias.rstrip("+-") for alias in target_aliases}
184
+ source_bases = {alias.rstrip("+-") for alias in next_source_aliases}
185
+
186
+ # Check if there's any overlap at the base level
187
+ return bool(target_bases & source_bases)
188
+
189
+ def _find_constructive_transitions(
190
+ current_target_aliases: list[str],
191
+ ) -> list[T]:
192
+ """
193
+ Find all transitions that can constructively connect to the current target.
194
+ """
195
+ constructive_transitions = []
196
+ all_transitions_inner = self.get_all_transitions()
197
+
198
+ for transition in all_transitions_inner:
199
+ if (
200
+ transition.predicate == Predicate.CONSTRUCTIVELY_CONVERGES_TO
201
+ and _can_connect_constructively(
202
+ current_target_aliases, transition.source_aliases
203
+ )
204
+ ):
205
+ constructive_transitions.append(transition)
206
+
207
+ return constructive_transitions
208
+
209
+ def _would_create_constructive_cycle(
210
+ current_aliases: list[str], visited_in_path: set
211
+ ) -> bool:
212
+ """
213
+ For constructively_converges_to, check if current aliases would create a cycle
214
+ by checking segment-level overlap with any visited node.
215
+ """
216
+ current_bases = {alias.rstrip("+-") for alias in current_aliases}
217
+
218
+ for visited_key in visited_in_path:
219
+ visited_bases = {alias.rstrip("+-") for alias in visited_key}
220
+ if current_bases & visited_bases: # If there's any base overlap
221
+ return True
222
+ return False
223
+
224
+ def _dfs_helper(
225
+ current_aliases: list[str],
226
+ current_path: list[T],
227
+ visited_in_path: set,
228
+ current_predicate: str | None = None,
229
+ ):
230
+ current_key = frozenset(current_aliases)
231
+
232
+ # Check for cycle based on predicate type
233
+ if current_predicate == Predicate.CONSTRUCTIVELY_CONVERGES_TO:
234
+ # For constructive convergence, check segment-level overlap
235
+ if _would_create_constructive_cycle(current_aliases, visited_in_path):
236
+ # Found a cycle in this path - record the path up to this point
237
+ all_paths.append(current_path.copy())
238
+ if visit_callback:
239
+ visit_callback(current_path, True)
240
+ return
241
+ else:
242
+ # For other predicates, use exact alias matching
243
+ if current_key in visited_in_path:
244
+ # Found a cycle in this path - record the path up to this point
245
+ all_paths.append(current_path.copy())
246
+ if visit_callback:
247
+ visit_callback(current_path, True)
248
+ return
249
+
250
+ # Add to current path's visited set
251
+ new_visited = visited_in_path | {current_key}
252
+
253
+ # Get transitions based on predicate type
254
+ if current_predicate == Predicate.CONSTRUCTIVELY_CONVERGES_TO:
255
+ # For constructive convergence, find transitions that can connect at segment level
256
+ transitions = _find_constructive_transitions(current_aliases)
257
+ else:
258
+ # Normal case: get transitions from exact source match
259
+ transitions = self.get_transitions_from_source(current_aliases)
260
+
261
+ # Filter transitions based on predicate requirements
262
+ if predicate_filter is not None:
263
+ # Only allow transitions with the specified predicate
264
+ transitions = [
265
+ t for t in transitions if t.predicate == predicate_filter
266
+ ]
267
+ elif current_predicate is not None:
268
+ # If we're already in a path with a specific predicate, only allow same predicate
269
+ transitions = [
270
+ t for t in transitions if t.predicate == current_predicate
271
+ ]
272
+
273
+ if not transitions:
274
+ # End of path - record it
275
+ all_paths.append(current_path.copy())
276
+ if visit_callback:
277
+ visit_callback(current_path, False)
278
+ return
279
+
280
+ # Explore each outgoing edge separately
281
+ for transition in transitions:
282
+ new_path = current_path + [transition]
283
+ target_key = frozenset(transition.target_aliases)
284
+
285
+ # Determine the predicate for the next step
286
+ next_predicate = (
287
+ current_predicate
288
+ if current_predicate is not None
289
+ else transition.predicate
290
+ )
291
+
292
+ # Check if target would create a cycle based on predicate type
293
+ if next_predicate == Predicate.CONSTRUCTIVELY_CONVERGES_TO:
294
+ # For constructive convergence, check segment-level cycle
295
+ if _would_create_constructive_cycle(
296
+ transition.target_aliases, new_visited
297
+ ):
298
+ # This transition would create a cycle, record the path including this transition
299
+ all_paths.append(new_path.copy())
300
+ if visit_callback:
301
+ visit_callback(new_path, True)
302
+ else:
303
+ # Normal case: continue DFS
304
+ _dfs_helper(
305
+ transition.target_aliases,
306
+ new_path,
307
+ new_visited,
308
+ next_predicate,
309
+ )
310
+ else:
311
+ # For other predicates, use exact matching
312
+ if target_key in new_visited:
313
+ # This transition would create a cycle, record the path including this transition
314
+ all_paths.append(new_path.copy())
315
+ if visit_callback:
316
+ visit_callback(new_path, True)
317
+ else:
318
+ # Normal case: continue DFS
319
+ _dfs_helper(
320
+ transition.target_aliases,
321
+ new_path,
322
+ new_visited,
323
+ next_predicate,
324
+ )
325
+
326
+ if start_aliases is None:
327
+ # Get all transitions and find unique source aliases
328
+ all_transitions = self.get_all_transitions()
329
+ if all_transitions:
330
+ first_transition = all_transitions[0]
331
+ start_aliases_list = first_transition.source_aliases
332
+ else:
333
+ return []
334
+ else:
335
+ start_aliases_list = _extract_aliases(start_aliases)
336
+
337
+ _dfs_helper(start_aliases_list, [], set())
338
+
339
+ return all_paths
340
+
341
+ def first_path(self, start_aliases: AliasInput | None = None) -> list[T]:
342
+ """
343
+ We normally deal with cycles and spirals, so first path is mostly enough.
344
+ """
345
+ # TODO: very inefficient, we shouldn't be traversing the whole graph just to get the first path
346
+ paths = self.traverse_dfs_with_paths(start_aliases)
347
+ return paths[0] if paths else []
348
+
349
+ def count_circles(self) -> int:
350
+ """
351
+ Count the number of distinct cycles (circles) in the directed graph.
352
+ Each unique cycle is counted only once, regardless of starting point.
353
+
354
+ Returns:
355
+ Number of distinct cycles found in the graph
356
+ """
357
+ if self.is_empty():
358
+ return 0
359
+
360
+ detected_cycles = set()
361
+
362
+ # Get all unique starting nodes
363
+ all_nodes = set()
364
+ for (src_set, tgt_set), _ in self._transitions.items():
365
+ all_nodes.update(src_set)
366
+ all_nodes.update(tgt_set)
367
+
368
+ # Check each node as a potential cycle start
369
+ for start_node in all_nodes:
370
+ paths = self.traverse_dfs_with_paths(start_node)
371
+
372
+ for path in paths:
373
+ if path: # Non-empty path
374
+ # Check if end connects back to start
375
+ last_transition = path[-1]
376
+ path_end = frozenset(last_transition.target_aliases)
377
+ path_start = frozenset([start_node])
378
+
379
+ if path_end == path_start:
380
+ # Found a cycle! Extract all nodes in the cycle
381
+ cycle_nodes = [
382
+ frozenset([start_node])
383
+ ] # Start with the starting node
384
+ for transition in path:
385
+ cycle_nodes.append(frozenset(transition.target_aliases))
386
+
387
+ # Remove the duplicate end node (it's the same as start)
388
+ cycle_nodes = cycle_nodes[:-1]
389
+
390
+ # Normalize: start from lexicographically smallest node
391
+ min_node = min(cycle_nodes)
392
+ min_idx = cycle_nodes.index(min_node)
393
+ normalized_cycle = tuple(
394
+ cycle_nodes[min_idx:] + cycle_nodes[:min_idx]
395
+ )
396
+
397
+ detected_cycles.add(normalized_cycle)
398
+
399
+ return len(detected_cycles)
400
+
401
+ def pretty(self, start_aliases: AliasInput | None = None) -> str:
402
+ paths = self.traverse_dfs_with_paths(start_aliases=start_aliases)
403
+ paths_pieces = []
404
+ for path in paths:
405
+ current_str = None
406
+ for i, transition in enumerate(path):
407
+ if current_str is None:
408
+ current_str = f"{transition.source_aliases}"
409
+ if i == len(path) - 1 and tuple(transition.target_aliases) == tuple(
410
+ path[0].source_aliases
411
+ ):
412
+ # Skip last node if it equals the first node (completing the cycle)
413
+ continue
414
+ current_str += f" -> {transition.target_aliases}"
415
+ paths_pieces.append(current_str)
416
+ return "\n".join(paths_pieces) if paths_pieces else "<empty>"
417
+
418
+ def __str__(self):
419
+ return self.pretty()
File without changes
@@ -0,0 +1,10 @@
1
+ from __future__ import annotations
2
+
3
+ from enum import Enum
4
+
5
+
6
+ class CausalityType(str, Enum):
7
+ REALISTIC = "realistic"
8
+ DESIRABLE = "desirable"
9
+ FEASIBLE = "feasible"
10
+ BALANCED = "balanced"
@@ -0,0 +1,11 @@
1
+ from enum import Enum
2
+
3
+
4
+ class DI(str, Enum):
5
+ """Dependency injection provider names, for easier refactoring"""
6
+
7
+ settings = "settings"
8
+ brain = "brain"
9
+ polarity_reasoner = "polarity_reasoner"
10
+ causality_sequencer = "causality_sequencer"
11
+ thesis_extractor = "thesis_extractor"
@@ -0,0 +1,9 @@
1
+ from __future__ import annotations
2
+
3
+ from enum import Enum
4
+
5
+
6
+ class DialecticalReasoningMode(str, Enum):
7
+ GENERAL_CONCEPTS = "general_concepts"
8
+ MAJOR_TENSION = "major_tension"
9
+ ACTION_REFLECTION = "action_reflection"
@@ -0,0 +1,9 @@
1
+ from __future__ import annotations
2
+
3
+ from enum import Enum
4
+
5
+
6
+ class Predicate(str, Enum):
7
+ CAUSES = "causes"
8
+ CONSTRUCTIVELY_CONVERGES_TO = "constructively_converges_to"
9
+ TRANSFORMS_TO = "transforms_to"
File without changes
@@ -0,0 +1,141 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import TYPE_CHECKING, final, TypeVar
5
+
6
+ from pydantic import BaseModel, ConfigDict, Field
7
+
8
+ from dialectical_framework.utils.gm import gm_with_zeros_and_nones_handled
9
+
10
+ if TYPE_CHECKING: # Conditionally import Rationale for type checking only
11
+ from dialectical_framework.analyst.domain.rationale import Rationale
12
+
13
+ class Assessable(BaseModel, ABC):
14
+ model_config = ConfigDict(
15
+ arbitrary_types_allowed=True,
16
+ )
17
+
18
+ score: float | None = Field(
19
+ default=None,
20
+ ge=0.0, le=1.0,
21
+ description="The final composite score (Pr(S) * CF_S^alpha) for ranking."
22
+ )
23
+
24
+ contextual_fidelity: float | None = Field(default=None, description="Grounding in the initial context")
25
+
26
+ probability: float | None = Field(
27
+ default=None,
28
+ ge=0.0, le=1.0,
29
+ description="The normalized probability (Pr(S)) of the cycle to exist in reality.",
30
+ )
31
+
32
+ rationales: list[Rationale] = Field(default_factory=list, description="Reasoning about this assessable instance")
33
+
34
+ @property
35
+ def best_rationale(self) -> Rationale | None:
36
+ if self.rationales and len(self.rationales) > 1:
37
+ return self.rationales[0]
38
+
39
+ selected_r = None
40
+ best_score = None # use None sentinel
41
+
42
+ for r in self.rationales or []:
43
+ r_score = r.calculate_score(mutate=False)
44
+ if r_score is not None and (best_score is None or r_score > best_score):
45
+ best_score = r_score
46
+ selected_r = r
47
+
48
+ if selected_r is not None:
49
+ return selected_r
50
+ # fallback: first rationale if present
51
+ return self.rationales[0] if self.rationales else None
52
+
53
+ @final
54
+ def calculate_score(self, *, alpha: float = 1.0, mutate: bool = True) -> float | None:
55
+ """
56
+ Calculates composite score: Score(X) = Pr(S) × CF_X^α
57
+
58
+ Two-layer weighting system:
59
+ - rating: Domain expert weighting (applied during aggregation)
60
+ - alpha: System-level parameter for contextual fidelity importance
61
+
62
+ Args:
63
+ alpha: Contextual fidelity exponent
64
+ < 1.0: De-emphasize expert context assessments
65
+ = 1.0: Respect expert ratings fully (default)
66
+ > 1.0: Amplify expert context assessments
67
+ """
68
+ # First, recursively calculate scores for all sub-assessables
69
+ sub_assessables = self._get_sub_assessables()
70
+ for sub_assessable in sub_assessables:
71
+ sub_assessable.calculate_score(alpha=alpha, mutate=mutate)
72
+
73
+ # Ensure that the overall probability has been calculated
74
+ probability = self.calculate_probability(mutate=mutate)
75
+ # Always calculate contextual fidelity, even if probability is None
76
+ cf_w = self.calculate_contextual_fidelity(mutate=mutate)
77
+
78
+ if probability is None:
79
+ # If still None, cannot calculate score
80
+ score = None
81
+ else:
82
+ score = probability * (cf_w ** alpha)
83
+
84
+ if mutate:
85
+ self.score = score
86
+
87
+ return self.score
88
+
89
+ def calculate_contextual_fidelity(self, *, mutate: bool = True) -> float:
90
+ """
91
+ If not possible to calculate contextual fidelity, return 1.0 to have neutral impact on overall scoring.
92
+
93
+ Normally this method shouldn't be called, as it's called by the `calculate_score` method.
94
+ """
95
+ all_fidelities = []
96
+ all_fidelities.extend(self._calculate_contextual_fidelity_for_rationales())
97
+ all_fidelities.extend(self._calculate_contextual_fidelity_for_sub_elements_excl_rationales())
98
+
99
+ if not all_fidelities:
100
+ fidelity = 1.0 # Neutral impact if no components with positive scores
101
+ else:
102
+ fidelity = gm_with_zeros_and_nones_handled(all_fidelities)
103
+
104
+ if mutate:
105
+ self.contextual_fidelity = fidelity
106
+
107
+ return fidelity
108
+
109
+ @abstractmethod
110
+ def calculate_probability(self, *, mutate: bool = True) -> float | None: ...
111
+ """
112
+ Normally this method shouldn't be called, as it's called by the `calculate_score` method.
113
+ """
114
+
115
+ def _get_sub_assessables(self) -> list[Assessable]:
116
+ """
117
+ Returns all direct sub-assessable elements contained within this assessable.
118
+ Used for recursive score calculation.
119
+ """
120
+ # IMPORTANT: we must work on a copy, to avoid filling rationales list with garbage
121
+ return [*self.rationales]
122
+
123
+ def _calculate_contextual_fidelity_for_sub_elements_excl_rationales(self, *, mutate: bool = True) -> list[float]:
124
+ return []
125
+
126
+ @final
127
+ def _calculate_contextual_fidelity_for_rationales(self, *, mutate: bool = True) -> list[float]:
128
+ fids: list[float] = []
129
+ if self.rationales:
130
+ for rationale in self.rationales:
131
+ # IMPORTANT: use the evidence view to avoid 1.0 fallback inflation
132
+ evidence_cf = rationale.calculate_contextual_fidelity_evidence(mutate=mutate)
133
+
134
+ if evidence_cf is not None and evidence_cf > 0.0:
135
+ weighted = evidence_cf * rationale.rating_or_default()
136
+ if weighted > 0.0: # skip non-positives after weighting
137
+ fids.append(weighted)
138
+ return fids
139
+
140
+ def _calculate_probability_for_sub_elements_excl_rationales(self, *, mutate: bool = True) -> list[float]:
141
+ return []