dreamforge 0.2.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.
- dreamforge/__init__.py +8 -0
- dreamforge/core/__init__.py +6 -0
- dreamforge/core/config.py +142 -0
- dreamforge/core/models/__init__.py +1 -0
- dreamforge/core/models/dream_context.py +323 -0
- dreamforge/core/models/events.py +310 -0
- dreamforge/core/models/memory_graph.py +385 -0
- dreamforge/core/models/neurochemistry.py +124 -0
- dreamforge/core/models/sleep_cycle.py +394 -0
- dreamforge/core/provenance/__init__.py +1 -0
- dreamforge/core/provenance/clock.py +38 -0
- dreamforge/core/providers/__init__.py +1 -0
- dreamforge/core/providers/narrative.py +230 -0
- dreamforge/core/scoring/__init__.py +1 -0
- dreamforge/core/scoring/bizarreness.py +87 -0
- dreamforge/core/serialization/__init__.py +1 -0
- dreamforge/core/serialization/dqcj.py +283 -0
- dreamforge/demo.py +120 -0
- dreamforge/integrations/__init__.py +5 -0
- dreamforge/integrations/anthropic_compat.py +174 -0
- dreamforge/integrations/errors.py +33 -0
- dreamforge/integrations/openai_compat.py +177 -0
- dreamforge/integrations/retry.py +72 -0
- dreamforge/integrations/transport.py +69 -0
- dreamforge/simulation/__init__.py +1 -0
- dreamforge/simulation/counterfactual.py +178 -0
- dreamforge/simulation/engine.py +310 -0
- dreamforge/simulation/ensemble.py +135 -0
- dreamforge/simulation/export_import.py +471 -0
- dreamforge/simulation/report.py +153 -0
- dreamforge/simulation/run_repository.py +176 -0
- dreamforge/simulation/sweeps.py +133 -0
- dreamforge/visualization/__init__.py +1 -0
- dreamforge/visualization/dashboard.py +183 -0
- dreamforge/visualization/loader.py +68 -0
- dreamforge-0.2.0.dist-info/METADATA +156 -0
- dreamforge-0.2.0.dist-info/RECORD +41 -0
- dreamforge-0.2.0.dist-info/WHEEL +5 -0
- dreamforge-0.2.0.dist-info/entry_points.txt +3 -0
- dreamforge-0.2.0.dist-info/licenses/LICENSE +21 -0
- dreamforge-0.2.0.dist-info/top_level.txt +1 -0
dreamforge/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""DreamForge — deterministic research and visualization simulator.
|
|
2
|
+
|
|
3
|
+
DreamForge is a research and visualization simulator. It does not measure
|
|
4
|
+
brains, diagnose conditions, predict dreams, infer psychological meaning, or
|
|
5
|
+
provide medical advice.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "0.2.0"
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Strict simulation configuration loading (fail closed).
|
|
2
|
+
|
|
3
|
+
The configuration is the only external input to a run. It is validated into
|
|
4
|
+
frozen Pydantic models before any engine object is constructed; any violation
|
|
5
|
+
raises :class:`ConfigError` with a machine-readable ``code``. No environment
|
|
6
|
+
variables, clocks, or network access participate.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
15
|
+
|
|
16
|
+
from dreamforge.core.models.events import ALL_STAGES, StageName
|
|
17
|
+
from dreamforge.core.models.memory_graph import GraphSpecConfig, ReplaySelectorConfig
|
|
18
|
+
from dreamforge.core.models.neurochemistry import NeurochemistryConfig
|
|
19
|
+
from dreamforge.core.models.sleep_cycle import (
|
|
20
|
+
CircadianConfig,
|
|
21
|
+
DwellDistribution,
|
|
22
|
+
ProcessSConfig,
|
|
23
|
+
TransitionMatrixConfig,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
#: Hard limits guarding against oversized/hostile configs (section 6.3).
|
|
27
|
+
MAX_EPOCH_SECONDS = 3600.0
|
|
28
|
+
MIN_EPOCH_SECONDS = 1.0
|
|
29
|
+
MAX_TOTAL_TICKS = 100_000
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ConfigError(ValueError):
|
|
33
|
+
"""Raised when configuration fails validation; ``code`` is stable."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, code: str, message: str) -> None:
|
|
36
|
+
super().__init__(message)
|
|
37
|
+
self.code = code
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ReplayPolicyConfig(BaseModel):
|
|
41
|
+
"""Where replay selection gets its graph and its policy."""
|
|
42
|
+
|
|
43
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
44
|
+
|
|
45
|
+
synthetic_graph: GraphSpecConfig = Field(default_factory=GraphSpecConfig)
|
|
46
|
+
selector: ReplaySelectorConfig = Field(default_factory=ReplaySelectorConfig)
|
|
47
|
+
replay_every_n_epochs: int = Field(default=8, ge=1)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class SimulationConfig(BaseModel):
|
|
51
|
+
"""Fully validated run configuration (immutable after load)."""
|
|
52
|
+
|
|
53
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
54
|
+
|
|
55
|
+
schema_version: str = Field(pattern=r"^\d+\.\d+$")
|
|
56
|
+
run_id: str = Field(min_length=8, max_length=64, pattern=r"^[A-Za-z0-9._-]+$")
|
|
57
|
+
run_seed: int = Field(ge=0, le=2**64 - 1)
|
|
58
|
+
epoch_seconds: float = Field(default=30.0)
|
|
59
|
+
total_ticks: int = Field(ge=1)
|
|
60
|
+
initial_stage: StageName = "Wake"
|
|
61
|
+
process_s: ProcessSConfig = Field(default_factory=ProcessSConfig)
|
|
62
|
+
circadian: CircadianConfig = Field(default_factory=CircadianConfig)
|
|
63
|
+
transitions: TransitionMatrixConfig
|
|
64
|
+
dwells: dict[StageName, DwellDistribution]
|
|
65
|
+
chemistry: NeurochemistryConfig
|
|
66
|
+
replay_policy: ReplayPolicyConfig = Field(default_factory=ReplayPolicyConfig)
|
|
67
|
+
|
|
68
|
+
@field_validator("epoch_seconds")
|
|
69
|
+
@classmethod
|
|
70
|
+
def _epoch_bounds(cls, value: float) -> float:
|
|
71
|
+
if not MIN_EPOCH_SECONDS <= value <= MAX_EPOCH_SECONDS:
|
|
72
|
+
msg = f"epoch_seconds must be within " f"[{MIN_EPOCH_SECONDS}, {MAX_EPOCH_SECONDS}]"
|
|
73
|
+
raise ValueError(msg)
|
|
74
|
+
return value
|
|
75
|
+
|
|
76
|
+
@field_validator("total_ticks")
|
|
77
|
+
@classmethod
|
|
78
|
+
def _ticks_cap(cls, value: int) -> int:
|
|
79
|
+
if value > MAX_TOTAL_TICKS:
|
|
80
|
+
msg = f"total_ticks exceeds MAX_TOTAL_TICKS={MAX_TOTAL_TICKS}"
|
|
81
|
+
raise ValueError(msg)
|
|
82
|
+
return value
|
|
83
|
+
|
|
84
|
+
@model_validator(mode="after")
|
|
85
|
+
def _dwell_support(self) -> SimulationConfig:
|
|
86
|
+
missing = [s for s in ALL_STAGES if s not in self.dwells]
|
|
87
|
+
if missing:
|
|
88
|
+
msg = f"dwells missing stages: {missing}"
|
|
89
|
+
raise ValueError(msg)
|
|
90
|
+
for stage, dwell in self.dwells.items():
|
|
91
|
+
longest = dwell.min_epochs + len(dwell.weights) - 1
|
|
92
|
+
if longest > self.total_ticks:
|
|
93
|
+
msg = f"dwell support for {stage} exceeds total_ticks"
|
|
94
|
+
raise ValueError(msg)
|
|
95
|
+
return self
|
|
96
|
+
|
|
97
|
+
def simulated_time_minutes(self, tick: int) -> float:
|
|
98
|
+
"""Documented conversion: tick × epoch_seconds / 60."""
|
|
99
|
+
return tick * self.epoch_seconds / 60.0
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def dumps_config_canonical(config: SimulationConfig) -> bytes:
|
|
103
|
+
"""Canonical configuration snapshot bytes (embedded into exports).
|
|
104
|
+
|
|
105
|
+
Lives here rather than in the simulation package so that export/import
|
|
106
|
+
verification never needs to load the engine module at all.
|
|
107
|
+
"""
|
|
108
|
+
from dreamforge.core.serialization.dqcj import dumps_canonical
|
|
109
|
+
|
|
110
|
+
return dumps_canonical(config.model_dump(mode="json"))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def load_config(source: str | Path | dict[str, Any]) -> SimulationConfig:
|
|
114
|
+
"""Load and strictly validate a run configuration.
|
|
115
|
+
|
|
116
|
+
``source`` may be an already-parsed dict or a path to UTF-8 JSON. Raises
|
|
117
|
+
:class:`ConfigError` on any violation (schema, ranges, structure).
|
|
118
|
+
"""
|
|
119
|
+
if isinstance(source, dict):
|
|
120
|
+
raw = source
|
|
121
|
+
else:
|
|
122
|
+
path = Path(source)
|
|
123
|
+
try:
|
|
124
|
+
text = path.read_text(encoding="utf-8")
|
|
125
|
+
except OSError as exc:
|
|
126
|
+
msg = f"config file unreadable: {exc.__class__.__name__}"
|
|
127
|
+
raise ConfigError("config_unreadable", msg) from exc
|
|
128
|
+
from dreamforge.core.serialization.dqcj import loads_strict
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
raw = loads_strict(text)
|
|
132
|
+
except ValueError as exc:
|
|
133
|
+
raise ConfigError("config_invalid_json", str(exc)) from exc
|
|
134
|
+
if not isinstance(raw, dict):
|
|
135
|
+
raise ConfigError(
|
|
136
|
+
"config_invalid_json",
|
|
137
|
+
"top-level configuration must be a JSON object",
|
|
138
|
+
)
|
|
139
|
+
try:
|
|
140
|
+
return SimulationConfig.model_validate(raw)
|
|
141
|
+
except Exception as exc: # noqa: BLE001 - re-raised typed below
|
|
142
|
+
raise ConfigError("config_validation_failed", str(exc)) from exc
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Domain models: sleep cycle, neurochemistry, memory graph, events."""
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""Deterministic dream context and segments (MASTER_PROMPT.md §5.4, §6.1).
|
|
2
|
+
|
|
3
|
+
``DreamContext``/``DreamSegment`` are immutable and built ONLY from structured
|
|
4
|
+
state and selected synthetic node IDs — never prose. They are pure post-run
|
|
5
|
+
projections of emitted events; constructing one never mutates core state and
|
|
6
|
+
providers can never write back (ADR 0003).
|
|
7
|
+
|
|
8
|
+
Every feature documents its evidence variables, missing-data behaviour
|
|
9
|
+
(missing evidence ⇒ value 0.0), category distribution source, normalization,
|
|
10
|
+
and bounds. Values are structured numbers in [0, 1], never prose scores.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
18
|
+
|
|
19
|
+
#: DQCJ-1 quantization registry additions for context payloads. The score is
|
|
20
|
+
#: quantized to 0.01 (two-decimal percentage points) at the canonical boundary;
|
|
21
|
+
#: features keep 0.000001 like other engine floats.
|
|
22
|
+
CONTEXT_QUANTIZATIONS: dict[str, str] = {
|
|
23
|
+
"features.scene_discontinuity": "0.000001",
|
|
24
|
+
"features.entity_incongruity": "0.000001",
|
|
25
|
+
"features.causal_implausibility": "0.000001",
|
|
26
|
+
"features.temporal_distortion": "0.000001",
|
|
27
|
+
"features.identity_instability": "0.000001",
|
|
28
|
+
"features.memory_blending_entropy": "0.000001",
|
|
29
|
+
"score_bizarreness_normalized": "0.000001",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class DreamSegment(BaseModel):
|
|
34
|
+
"""One contiguous stage episode with its replay selections."""
|
|
35
|
+
|
|
36
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
37
|
+
|
|
38
|
+
segment_index: int = Field(ge=0)
|
|
39
|
+
stage: str
|
|
40
|
+
start_tick: int = Field(ge=0)
|
|
41
|
+
end_tick: int = Field(ge=0) # inclusive epoch of the episode's last tick
|
|
42
|
+
selected_node_ids: tuple[str, ...] = ()
|
|
43
|
+
|
|
44
|
+
@model_validator(mode="after")
|
|
45
|
+
def _order(self) -> DreamSegment:
|
|
46
|
+
if self.end_tick < self.start_tick:
|
|
47
|
+
msg = "end_tick must be >= start_tick"
|
|
48
|
+
raise ValueError(msg)
|
|
49
|
+
return self
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ContextFeatures(BaseModel):
|
|
53
|
+
"""The six bounded [0, 1] structured features (§5.4)."""
|
|
54
|
+
|
|
55
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
56
|
+
|
|
57
|
+
scene_discontinuity: float = Field(ge=0.0, le=1.0)
|
|
58
|
+
entity_incongruity: float = Field(ge=0.0, le=1.0)
|
|
59
|
+
causal_implausibility: float = Field(ge=0.0, le=1.0)
|
|
60
|
+
temporal_distortion: float = Field(ge=0.0, le=1.0)
|
|
61
|
+
identity_instability: float = Field(ge=0.0, le=1.0)
|
|
62
|
+
memory_blending_entropy: float = Field(ge=0.0, le=1.0)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class DreamContext(BaseModel):
|
|
66
|
+
"""Immutable structured context over one completed run."""
|
|
67
|
+
|
|
68
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
69
|
+
|
|
70
|
+
run_id: str
|
|
71
|
+
schema_version: str
|
|
72
|
+
total_ticks: int = Field(ge=1)
|
|
73
|
+
segments: tuple[DreamSegment, ...]
|
|
74
|
+
features: ContextFeatures
|
|
75
|
+
score_bizarreness_0_100: float = Field(ge=0.0, le=100.0)
|
|
76
|
+
scorer_version: str
|
|
77
|
+
output_class: str = "mechanistic_proxy"
|
|
78
|
+
visible_label: str = "Simulated model proxy — not a biological measurement"
|
|
79
|
+
|
|
80
|
+
@model_validator(mode="after")
|
|
81
|
+
def _segments_ordered(self) -> DreamContext:
|
|
82
|
+
previous_end = -1
|
|
83
|
+
for seg in self.segments:
|
|
84
|
+
if seg.start_tick <= previous_end:
|
|
85
|
+
msg = "segments must be strictly ordered, non-overlapping"
|
|
86
|
+
raise ValueError(msg)
|
|
87
|
+
previous_end = seg.end_tick
|
|
88
|
+
return self
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def build_segments(events: list[Any], *, min_segment_ticks: int = 2) -> tuple[DreamSegment, ...]:
|
|
92
|
+
"""Group consecutive same-stage epochs into segments.
|
|
93
|
+
|
|
94
|
+
Evidence: ``sleep_state.tick`` + ``stage`` per epoch; other event types
|
|
95
|
+
carry no stage evidence and are ignored. Every stage episode — including
|
|
96
|
+
short ones — is preserved as its own segment so the context never
|
|
97
|
+
mislabels stages (``min_segment_ticks`` is retained for API stability and
|
|
98
|
+
currently does not merge, documented honestly here).
|
|
99
|
+
"""
|
|
100
|
+
if not events:
|
|
101
|
+
return ()
|
|
102
|
+
# Only sleep_state events carry stage evidence.
|
|
103
|
+
state_events = [e for e in events if e.event_type == "sleep_state"]
|
|
104
|
+
if not state_events:
|
|
105
|
+
return ()
|
|
106
|
+
episodes: list[tuple[str, int, int]] = []
|
|
107
|
+
stage = str(state_events[0].payload.stage)
|
|
108
|
+
start = int(state_events[0].tick)
|
|
109
|
+
current = start
|
|
110
|
+
for event in state_events[1:]:
|
|
111
|
+
tick = int(event.tick)
|
|
112
|
+
event_stage = str(event.payload.stage)
|
|
113
|
+
if event_stage != stage or tick != current + 1:
|
|
114
|
+
episodes.append((stage, start, current))
|
|
115
|
+
stage, start = event_stage, tick
|
|
116
|
+
current = tick
|
|
117
|
+
episodes.append((stage, start, current))
|
|
118
|
+
|
|
119
|
+
merged: list[list[int]] = []
|
|
120
|
+
labels: list[str] = []
|
|
121
|
+
for stage_name, ep_start, ep_end in episodes:
|
|
122
|
+
merged.append([ep_start, ep_end])
|
|
123
|
+
labels.append(stage_name)
|
|
124
|
+
|
|
125
|
+
return tuple(
|
|
126
|
+
DreamSegment(
|
|
127
|
+
segment_index=index,
|
|
128
|
+
stage=label,
|
|
129
|
+
start_tick=span[0],
|
|
130
|
+
end_tick=span[1],
|
|
131
|
+
)
|
|
132
|
+
for index, (label, span) in enumerate(zip(labels, merged, strict=True))
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# --- structured features (§5.4) --------------------------------------------
|
|
137
|
+
#
|
|
138
|
+
# Each feature documents: evidence variables, missing-data behaviour (missing
|
|
139
|
+
# evidence ⇒ 0.0), normalization (explicit denominator), and bounds. All are
|
|
140
|
+
# deterministic functions over emitted events/segments only.
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _replays_in(events: list[Any]) -> list[Any]:
|
|
144
|
+
return [e for e in events if e.event_type == "memory_replay"]
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def feature_scene_discontinuity(segments: tuple[DreamSegment, ...], total_ticks: int) -> float:
|
|
148
|
+
"""Stage-transition density per epoch, normalized by observed maximum.
|
|
149
|
+
|
|
150
|
+
Evidence: segment boundaries. Missing data (no segments) ⇒ 0.0.
|
|
151
|
+
Normalizer: 1 transition per 10 epochs is mapped to the full value — a
|
|
152
|
+
declared calibration constant of this scorer version, not an empirical fit.
|
|
153
|
+
"""
|
|
154
|
+
if len(segments) < 2 or total_ticks <= 0:
|
|
155
|
+
return 0.0
|
|
156
|
+
transitions = len(segments) - 1
|
|
157
|
+
raw = transitions / max(total_ticks - 1, 1)
|
|
158
|
+
return min(raw / 0.1, 1.0)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def feature_entity_incongruity(
|
|
162
|
+
type_sets: list[set[str]],
|
|
163
|
+
) -> float:
|
|
164
|
+
"""Mean type-mix breadth across replay selections.
|
|
165
|
+
|
|
166
|
+
Evidence: the set of distinct node types in each replay's selected IDs
|
|
167
|
+
(types joined from the graph snapshot). Normalization: selections drawing
|
|
168
|
+
from all 6 node types map to 1.0 (declared constant of this scorer
|
|
169
|
+
version); missing selections ⇒ 0.0.
|
|
170
|
+
"""
|
|
171
|
+
if not type_sets:
|
|
172
|
+
return 0.0
|
|
173
|
+
mixes = [min(len(types) / 6.0, 1.0) for types in type_sets]
|
|
174
|
+
return sum(mixes) / len(mixes)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def feature_causal_implausibility(events: list[Any]) -> float:
|
|
178
|
+
"""Rate of N3→REM direct transitions among all stage transitions.
|
|
179
|
+
|
|
180
|
+
Declared hypothesis-tagged proxy pattern: deep-slowed cortex followed
|
|
181
|
+
immediately by REM-like activity is the configured 'implausible' pair for
|
|
182
|
+
this toy grammar. Missing transitions ⇒ 0.0.
|
|
183
|
+
"""
|
|
184
|
+
transitions = [e for e in events if e.event_type == "stage_transition"]
|
|
185
|
+
if not transitions:
|
|
186
|
+
return 0.0
|
|
187
|
+
implausible = sum(
|
|
188
|
+
1 for e in transitions if e.payload.from_stage == "N3" and e.payload.to_stage == "REM"
|
|
189
|
+
)
|
|
190
|
+
return min(implausible / len(transitions), 1.0)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def feature_temporal_distortion(segments: tuple[DreamSegment, ...]) -> float:
|
|
194
|
+
"""Coefficient of variation of segment lengths, squashed to [0, 1].
|
|
195
|
+
|
|
196
|
+
Evidence: epoch spans of segments. A perfectly regular structure maps
|
|
197
|
+
toward 0; wildly irregular lengths map toward 1 (bounded via
|
|
198
|
+
CV/(1+CV)). Fewer than two segments ⇒ missing evidence ⇒ 0.0.
|
|
199
|
+
"""
|
|
200
|
+
if len(segments) < 2:
|
|
201
|
+
return 0.0
|
|
202
|
+
lengths = [s.end_tick - s.start_tick + 1 for s in segments]
|
|
203
|
+
mean_length = sum(lengths) / len(lengths)
|
|
204
|
+
if mean_length <= 0:
|
|
205
|
+
return 0.0
|
|
206
|
+
variance = sum((n - mean_length) ** 2 for n in lengths) / len(lengths)
|
|
207
|
+
cv = (variance**0.5) / mean_length
|
|
208
|
+
return float(min(cv / (1.0 + cv), 1.0))
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def feature_identity_instability(replays: list[Any]) -> float:
|
|
212
|
+
"""Mean fraction of newly-introduced nodes across consecutive selections.
|
|
213
|
+
|
|
214
|
+
Evidence: consecutive ``selected_node_ids`` sets. One selection or none
|
|
215
|
+
⇒ missing evidence ⇒ 0.0. Identical successive selections ⇒ 0.0.
|
|
216
|
+
"""
|
|
217
|
+
if len(replays) < 2:
|
|
218
|
+
return 0.0
|
|
219
|
+
fractions: list[float] = []
|
|
220
|
+
previous: frozenset[str] | None = None
|
|
221
|
+
for event in replays:
|
|
222
|
+
current = frozenset(event.payload.selected_node_ids)
|
|
223
|
+
if previous is not None and previous:
|
|
224
|
+
fresh = len(current - previous)
|
|
225
|
+
fractions.append(fresh / len(previous))
|
|
226
|
+
previous = current
|
|
227
|
+
if not fractions:
|
|
228
|
+
return 0.0
|
|
229
|
+
return min(sum(fractions) / len(fractions), 1.0)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def feature_memory_blending_entropy(replays: list[Any]) -> float:
|
|
233
|
+
"""Shannon entropy of node-selection frequencies over the run.
|
|
234
|
+
|
|
235
|
+
Evidence: every selected ID across replays. Normalized by ln(k) where k =
|
|
236
|
+
number of DISTINCT nodes observed (documented denominator); k < 2 ⇒ 0.0.
|
|
237
|
+
Uniform coverage of all observed nodes approaches 1.0.
|
|
238
|
+
"""
|
|
239
|
+
import math
|
|
240
|
+
|
|
241
|
+
counts: dict[str, int] = {}
|
|
242
|
+
for event in replays:
|
|
243
|
+
for node_id in event.payload.selected_node_ids:
|
|
244
|
+
counts[node_id] = counts.get(node_id, 0) + 1
|
|
245
|
+
total_selections = sum(counts.values())
|
|
246
|
+
if total_selections == 0 or len(counts) < 2:
|
|
247
|
+
return 0.0
|
|
248
|
+
entropy = -sum(
|
|
249
|
+
(count / total_selections) * math.log(count / total_selections, math.e)
|
|
250
|
+
for count in counts.values()
|
|
251
|
+
)
|
|
252
|
+
return min(entropy / math.log(len(counts), math.e), 1.0)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def build_dream_context(
|
|
256
|
+
*,
|
|
257
|
+
run_id: str,
|
|
258
|
+
schema_version: str,
|
|
259
|
+
total_ticks: int,
|
|
260
|
+
events: list[Any],
|
|
261
|
+
node_type_lookup: dict[str, str] | None = None,
|
|
262
|
+
weights: dict[str, float] | None = None,
|
|
263
|
+
) -> DreamContext:
|
|
264
|
+
"""Construct the immutable context from emitted events (pure function)."""
|
|
265
|
+
from dreamforge.core.scoring.bizarreness import score_bizarreness
|
|
266
|
+
|
|
267
|
+
segments = build_segments(events)
|
|
268
|
+
replays = _replays_in(events)
|
|
269
|
+
|
|
270
|
+
# Attach each epoch's replay selections to the segment containing it so
|
|
271
|
+
# downstream consumers (provider projection) see tokens per episode.
|
|
272
|
+
if segments and replays:
|
|
273
|
+
selections_by_tick: dict[int, tuple[str, ...]] = {
|
|
274
|
+
int(event.tick): tuple(event.payload.selected_node_ids) for event in replays
|
|
275
|
+
}
|
|
276
|
+
attached: list[DreamSegment] = []
|
|
277
|
+
for segment in segments:
|
|
278
|
+
token_ids: list[str] = []
|
|
279
|
+
for tick in range(segment.start_tick, segment.end_tick + 1):
|
|
280
|
+
for node_id in selections_by_tick.get(tick, ()):
|
|
281
|
+
if node_id not in token_ids:
|
|
282
|
+
token_ids.append(node_id)
|
|
283
|
+
if token_ids:
|
|
284
|
+
attached.append(
|
|
285
|
+
segment.model_copy(update={"selected_node_ids": tuple(token_ids)}),
|
|
286
|
+
)
|
|
287
|
+
else:
|
|
288
|
+
attached.append(segment)
|
|
289
|
+
segments = tuple(attached)
|
|
290
|
+
|
|
291
|
+
# Entity incongruity joins node types from the caller-supplied lookup
|
|
292
|
+
# (built from the graph snapshot); without it, documented missing-data
|
|
293
|
+
# behaviour applies (0.0).
|
|
294
|
+
type_sets: list[set[str]] = []
|
|
295
|
+
if node_type_lookup:
|
|
296
|
+
type_sets = [
|
|
297
|
+
{
|
|
298
|
+
node_type_lookup.get(node_id, "unknown")
|
|
299
|
+
for node_id in event.payload.selected_node_ids
|
|
300
|
+
}
|
|
301
|
+
for event in replays
|
|
302
|
+
]
|
|
303
|
+
entity_value = feature_entity_incongruity(type_sets)
|
|
304
|
+
|
|
305
|
+
features = ContextFeatures(
|
|
306
|
+
scene_discontinuity=feature_scene_discontinuity(segments, total_ticks),
|
|
307
|
+
entity_incongruity=entity_value,
|
|
308
|
+
causal_implausibility=feature_causal_implausibility(events),
|
|
309
|
+
temporal_distortion=feature_temporal_distortion(segments),
|
|
310
|
+
identity_instability=feature_identity_instability(replays),
|
|
311
|
+
memory_blending_entropy=feature_memory_blending_entropy(replays),
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
normalized, absolute = score_bizarreness(features, weights=weights)
|
|
315
|
+
return DreamContext(
|
|
316
|
+
run_id=run_id,
|
|
317
|
+
schema_version=schema_version,
|
|
318
|
+
total_ticks=max(total_ticks, 1),
|
|
319
|
+
segments=segments,
|
|
320
|
+
features=features,
|
|
321
|
+
score_bizarreness_0_100=absolute,
|
|
322
|
+
scorer_version="bizarreness-v1",
|
|
323
|
+
)
|