Skip to content

Theoretical Enrichment & Tenability Evaluation

Theoretical Enrichment is a modular post-processing subsystem implementing dynamic Theory-Element induction (\(\Phi_{\text{spec}}\)) and structuralist tenability evaluation (\(TS_{\text{local}}, TS_{\text{edge}}\)) for the Episteme pipeline.

It bridges the domain-agnostic structural graph (\(\Phi_{\text{gen}}\)) produced by Phases 1–6 with formal structuralist metatheory (Stegmüller, 1976; Balzer et al., 1987; Schurz, 2024), without hardcoding any domain-specific scientific frameworks.


Architectural Role & Two-Stage Pipeline

flowchart TD
    subgraph CorePipeline ["Core Pipeline (Phases 1-6)"]
        UP["Extracted Graph:<br>TheoreticalHypothesis (Partition A)<br>ObservationUnit (Partition B)<br>Relations (EXPLAINS, SUPPORTS, CONSTRAINS)"]
    end

    subgraph Stage1 ["Stage 1: Macro Theory Induction (Graph Scope)"]
        TI["LLMTheoryInducer<br>(Discovers active T = ⟨K⟩ without hardcoded schemas)"]
        REG["TheoryRegistry<br>(M_pp dimensions, M_p parameters, Laws M)"]
        UP --> TI --> REG
    end

    subgraph Stage2 ["Stage 2: Micro Cluster Projection & Evaluation (Cluster Scope)"]
        CLUST["Empirical Clusters (Intended Applications I_k ⊆ M_pp)"]
        TP["LLMTheoryProjector<br>(Projects cluster into candidate M_p space)"]
        SOLV["TenabilitySolver<br>(Safe AST Law Evaluator + Blur Minimization)"]
        REG --> TP
        CLUST --> TP
        TP -->|"Phi_spec(I_k)"| SOLV
    end

    SOLV -->|"Enriched Parameters & TS Scores"| NEO4J[("Neo4j Projection Graph")]
    SOLV -->|"TheoreticalEnrichmentArtifact"| ARTS[(".pipeline_artifacts")]

The Four Processing Steps

Stage 0 / Step 1: Dynamic Theory-Element Induction (Macro Scope)

  • If the TheoryRegistry is unseeded, LLMTheoryInducer scans the entire graph's TheoreticalHypothesis nodes (Partition \(A\)) and empirical observation types (Partition \(B\)).
  • Induces up to max_theories active Theory-Elements (\(T = \langle K, I \rangle\)), extracting:
  • Non-theoretical empirical dimensions (\(M_{pp}\)).
  • Latent theoretical parameters (\(M_p\)).
  • Core mathematical constraint laws (\(M\)) with symbolic formulas (e.g. abs(P1 - P2) * 0.5).
  • Automatically registers the induced theories into TheoryRegistry.

Step 2: Cluster Mapping & Domain-Specific Projection (\(\Phi_{\text{spec}}\))

  • Groups ObservationUnit and EmpiricalStatement nodes into empirical clusters representing Intended Applications (\(I \subseteq M_{pp}\)).
  • Evaluates each cluster through the claiming theory's lens via LLMTheoryProjector or structured measurement lookups.
  • Estimates candidate latent theoretical parameter values \(\Phi_{\text{spec}}(I_k) \in [0.0, 1.0]\).

Step 3: Local Tenability Calculation (\(TS_{\text{local}}\))

  • Evaluates core laws using the AST-based SafeFormulaEvaluator (zero eval() security risk): $\(TS_{\text{local}}(y, M) = \sup \{ 1 - \delta \mid \exists x^* \in M : (\Phi(y), x^*) \in u_\delta \}\)$
  • Determines the tightest admissible blur \(\delta^*\) reconciling postulated parameters with core laws \(M\).

Step 4: Global and Intertheoretical Consistency (\(GL\))

  • Evaluates CONSTRAINS and REDUCES_TO edges across clusters and models: $\(TS_{\text{edge}}(e) = \sup \{ 1 - \delta_C \mid (\Phi(y_a), \Phi(y_b)) \in v_{\delta_C} \}\)$
  • Flags edges or hypotheses with \(TS < 0.5\) as untenable anomalies.

Python API Reference

episteme_pipeline.post_processing.theoretical_enrichment.runner.TheoreticalEnrichmentRunner

Bases: PhaseRunner[Phase4ArtifactsView]

Executes Theoretical Enrichment & Tenability Evaluation post-processing.

Parameters

config : TheoreticalEnrichmentConfig | None, optional Configuration block for Theoretical Enrichment, by default None. schema : SchemaConfig | None, optional Decoupled schema configuration, by default None. graph_store : ProjectionGraph | None, optional Graph backend to project enriched parameters and scores into, by default None. registry : TheoryRegistry | None, optional Registry of formal Theory-Element definitions, by default None. projector : TheoryProjector | None, optional Domain projection engine (Phi_spec), by default None. solver : TenabilitySolver | None, optional Tenability optimization solver, by default None. inducer : TheoryInducer | None, optional Dynamic Theory-Element induction engine, by default None. llm : Any | None, optional Underlying LLM facade for induction and projection, by default None.

Source code in packages/episteme-pipeline/episteme_pipeline/post_processing/theoretical_enrichment/runner.py
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
class TheoreticalEnrichmentRunner(PhaseRunner[Phase4ArtifactsView]):
    """Executes Theoretical Enrichment & Tenability Evaluation post-processing.

    Parameters
    ----------
    config : TheoreticalEnrichmentConfig | None, optional
        Configuration block for Theoretical Enrichment, by default None.
    schema : SchemaConfig | None, optional
        Decoupled schema configuration, by default None.
    graph_store : ProjectionGraph | None, optional
        Graph backend to project enriched parameters and scores into, by default None.
    registry : TheoryRegistry | None, optional
        Registry of formal Theory-Element definitions, by default None.
    projector : TheoryProjector | None, optional
        Domain projection engine (Phi_spec), by default None.
    solver : TenabilitySolver | None, optional
        Tenability optimization solver, by default None.
    inducer : TheoryInducer | None, optional
        Dynamic Theory-Element induction engine, by default None.
    llm : Any | None, optional
        Underlying LLM facade for induction and projection, by default None.
    """

    name = "Theoretical Enrichment & Tenability Evaluation"
    phase_key = "theoretical_enrichment"
    input_view = Phase4ArtifactsView

    def __init__(
        self,
        config: TheoreticalEnrichmentConfig | None = None,
        schema: SchemaConfig | None = None,
        graph_store: ProjectionGraph | None = None,
        registry: TheoryRegistry | None = None,
        projector: TheoryProjector | None = None,
        solver: TenabilitySolver | None = None,
        inducer: TheoryInducer | None = None,
        llm: Any | None = None,
    ) -> None:
        self.config = config or TheoreticalEnrichmentConfig()
        self.schema = schema or SchemaConfig()
        self.graph_store = graph_store
        self.registry = registry or TheoryRegistry()
        self.llm: StructuredLLM | None = ensure_structured_llm(llm) if llm is not None else None

        if inducer is not None:
            self.inducer: TheoryInducer | None = inducer
        elif self.llm is not None and self.config.induce_theories:
            self.inducer = LLMTheoryInducer(
                llm=self.llm,
                prompts=self.config.induction_prompts,
                strategy=self.config.decoding_strategy,
            )
        else:
            self.inducer = None

        if projector is not None:
            self.projector = projector
        elif self.llm is not None:
            self.projector = LLMTheoryProjector(
                llm=self.llm,
                prompts=self.config.projection_prompts,
                strategy=self.config.decoding_strategy,
            )
        else:
            self.projector = CompositeTheoryProjector()

        self.solver = solver or TenabilitySolver(
            anomaly_threshold=self.config.tenability_threshold,
            weight_local=self.config.weight_local,
            weight_edge=self.config.weight_edge,
        )

    @property
    def event_emitter(self) -> EventEmitter:
        """Centralized event emitter."""
        return get_event_emitter()

    async def run(
        self, input: Phase4ArtifactsView, context: ArtifactExecutionContext
    ) -> ArtifactCollection:
        """Run the Theoretical Enrichment and Tenability Evaluation post-processor.

        Parameters
        ----------
        input : Phase4ArtifactsView
            Typed slice of upstream Phase 4/5 theory atoms and relations.
        context : ArtifactExecutionContext
            Execution run context and manifest tracking.

        Returns
        -------
        ArtifactCollection
            Collection of emitted TheoreticalEnrichmentArtifact envelopes.
        """
        logger.info(
            "Theoretical Enrichment: Beginning evaluation on %d atoms and %d relations",
            len(input.theory_atoms),
            len(input.theory_relations),
        )

        if not self.config.enabled:
            logger.info("Theoretical Enrichment is disabled in config. Skipping.")
            return ArtifactCollection([])

        # -------------------------------------------------------------
        # Stage 0: Dynamic Theory-Element Induction (Macro Scope)
        # -------------------------------------------------------------
        if not self.registry.all_theories() and self.config.induce_theories and self.inducer:
            logger.info("Theoretical Enrichment: Registry is unseeded. Running LLM Theory-Element induction...")
            induced = await self.inducer.induce_theories(
                atoms=input.theory_atoms,
                relations=input.theory_relations,
                schema=self.schema,
                max_theories=self.config.max_theories,
            )
            for theory in induced:
                self.registry.register(theory)
            logger.info(
                "Theoretical Enrichment: Successfully induced and registered %d theories: %s",
                len(induced),
                [t.theory_id for t in induced],
            )

        # -------------------------------------------------------------
        # Step 1: Cluster Mapping (Application Identification)
        # -------------------------------------------------------------
        clusters = self._identify_empirical_clusters(
            input.theory_atoms, input.theory_relations
        )
        logger.info(
            "Theoretical Enrichment: Identified %d empirical clusters (Intended Applications I)",
            len(clusters),
        )

        # Map each cluster to claiming theories
        for cluster in clusters:
            claimants = self.registry.match_claimants(cluster)
            if not claimants:
                # Default to all registered theories for evaluation
                claimants = [t.theory_id for t in self.registry.all_theories()]
            cluster.claimant_theory_ids = claimants

            self.event_emitter.emit(
                TheoreticalClusterIdentified(
                    run_id=context.run_id,
                    phase=self.name,
                    cluster_id=cluster.cluster_id,
                    observation_count=len(cluster.observations),
                    claimant_theories=claimants,
                )
            )

        # -------------------------------------------------------------
        # Step 2: Domain-Specific Projections (Phi_spec)
        # Step 3: Local Tenability Calculation (TS_local)
        # Step 4: Global and Intertheoretical Consistency (GL)
        # -------------------------------------------------------------
        enrichment_artifacts: list[ArtifactEnvelope[TheoreticalEnrichmentArtifact]] = []
        cluster_theory_params: dict[tuple[str, str], dict[str, Any]] = {}
        atoms_to_update: list[TheoryAtom] = []
        relations_to_update: list[dict[str, Any]] = []

        atom_by_id = {a.id: a for a in input.theory_atoms}

        for cluster in clusters:
            for theory_id in cluster.claimant_theory_ids:
                theory = self.registry.get(theory_id)
                if not theory:
                    continue

                # Step 2: Project cluster through the theory-specific lens (Phi_spec)
                if hasattr(self.projector, "aproject"):
                    projected_params = await self.projector.aproject(cluster, theory)
                else:
                    projected_params = self.projector.project(cluster, theory)
                cluster_theory_params[(cluster.cluster_id, theory_id)] = projected_params

                self.event_emitter.emit(
                    TheoreticalParametersProjected(
                        run_id=context.run_id,
                        phase=self.name,
                        cluster_id=cluster.cluster_id,
                        theory_id=theory_id,
                        parameters=projected_params,
                    )
                )

                # Step 3: Local Tenability Calculation (TS_local)
                ts_local, delta_star, local_anomalies = self.solver.solve_local_tenability(
                    projected_params, theory
                )

                for anom in local_anomalies:
                    self.event_emitter.emit(
                        TenabilityAnomalyDetected(
                            run_id=context.run_id,
                            phase=self.name,
                            element_id=cluster.cluster_id,
                            theory_id=theory_id,
                            score=ts_local,
                            reason=anom,
                        )
                    )

                # Step 4: Edge Tenability Calculation (TS_edge) across cluster relations
                edge_scores: dict[str, float] = {}
                edge_anomalies: list[str] = []

                for rel in cluster.relations:
                    rel_key = f"{rel.source_id}->{rel.relation_type}->{rel.target_id}"

                    # Look up counterpart params (either target node's cluster or sibling theory)
                    target_params = self._find_target_parameters(
                        rel.target_id, clusters, cluster_theory_params, theory_id
                    )

                    ts_edge, delta_c, anoms = self.solver.solve_edge_tenability(
                        source_params=projected_params,
                        target_params=target_params,
                        relation=rel,
                    )
                    edge_scores[rel_key] = ts_edge
                    edge_anomalies.extend(anoms)

                    for anom in anoms:
                        self.event_emitter.emit(
                            TenabilityAnomalyDetected(
                                run_id=context.run_id,
                                phase=self.name,
                                element_id=rel_key,
                                theory_id=theory_id,
                                score=ts_edge,
                                reason=anom,
                            )
                        )

                    # Stage relation for graph update
                    rel_copy = rel.model_copy()
                    rel_copy.tenability = ts_edge
                    relations_to_update.append(
                        {
                            "from_id": rel.source_id,
                            "relation_type": rel.relation_type,
                            "to_id": rel.target_id,
                            "properties": {
                                "confidence": rel.confidence,
                                "scope": rel.scope,
                                "tenability": ts_edge,
                                "weight": rel.weight,
                            },
                        }
                    )

                tenability_res = self.solver.evaluate_theory_tenability(
                    local_score=ts_local,
                    edge_scores=edge_scores,
                    delta_star=delta_star,
                    local_anomalies=local_anomalies,
                    edge_anomalies=edge_anomalies,
                )

                self.event_emitter.emit(
                    TenabilityEvaluationCompleted(
                        run_id=context.run_id,
                        phase=self.name,
                        theory_id=theory_id,
                        local_score=ts_local,
                        aggregated_score=tenability_res.aggregated_score,
                        is_tenable=tenability_res.is_tenable,
                        tightest_blur=delta_star,
                    )
                )

                # Attach parameters and scores to TheoreticalHypothesis nodes in the cluster
                for obs in cluster.observations:
                    if self.schema.component_partitions.get(obs.component_type) == "A":
                        obs_updated = obs.model_copy()
                        obs_updated.parameters.update(projected_params)
                        obs_updated.tenability = tenability_res
                        atoms_to_update.append(obs_updated)

                enrichment_payload = TheoreticalEnrichmentArtifact(
                    enrichment_id=f"enrichment-{uuid4()}",
                    cluster_id=cluster.cluster_id,
                    theory_id=theory_id,
                    projected_parameters=projected_params,
                    local_tenability=ts_local,
                    edge_tenabilities=edge_scores,
                    aggregated_tenability=tenability_res.aggregated_score,
                    admissible_blur_delta=delta_star,
                    is_tenable=tenability_res.is_tenable,
                    anomalies=tenability_res.anomalies,
                )

                envelope = ArtifactEnvelope[TheoreticalEnrichmentArtifact](
                    artifact_id=enrichment_payload.enrichment_id,
                    kind=ArtifactKind.THEORETICAL_ENRICHMENT,
                    run_id=context.run_id,
                    phase_name=self.name,
                    method="theoretical_enrichment",
                    payload=enrichment_payload,
                    provenance=ArtifactProvenance(
                        source_chunk_id=cluster.observations[0].source_chunk_id
                        if cluster.observations
                        else None
                    ),
                )
                enrichment_artifacts.append(envelope)

        # -------------------------------------------------------------
        # Step 5: Graph Persistence
        # -------------------------------------------------------------
        if self.graph_store:
            from itertools import batched

            if atoms_to_update:
                for batch in batched(atoms_to_update, 500):
                    await self.graph_store.upsert_argument_components(list(batch))

            if relations_to_update:
                for batch in batched(relations_to_update, 500):
                    await self.graph_store.upsert_relations(list(batch))

            logger.info(
                "Theoretical Enrichment: Projected %d enriched TheoryAtoms and %d relations to graph store",
                len(atoms_to_update),
                len(relations_to_update),
            )

        return ArtifactCollection(enrichment_artifacts)

    def _identify_empirical_clusters(
        self, atoms: list[TheoryAtom], relations: list[TheoryRelation]
    ) -> list[EmpiricalCluster]:
        """Group ObservationUnit nodes into empirical clusters (Intended Applications).

        Parameters
        ----------
        atoms : list[TheoryAtom]
            Input theory atoms.
        relations : list[TheoryRelation]
            Input theory relations.

        Returns
        -------
        list[EmpiricalCluster]
            List of empirical clusters.
        """
        # Group observation units by chunk id as base empirical partitions
        clusters_by_chunk: dict[str, list[TheoryAtom]] = {}
        for atom in atoms:
            clusters_by_chunk.setdefault(atom.source_chunk_id, []).append(atom)

        rel_by_chunk: dict[str, list[TheoryRelation]] = {}
        atom_chunk_map = {a.id: a.source_chunk_id for a in atoms}
        for rel in relations:
            chunk_s = atom_chunk_map.get(rel.source_id)
            if chunk_s:
                rel_by_chunk.setdefault(chunk_s, []).append(rel)

        clusters: list[EmpiricalCluster] = []
        for chunk_id, cluster_atoms in clusters_by_chunk.items():
            cluster_id = f"cluster-{chunk_id}"
            cluster_rels = rel_by_chunk.get(chunk_id, [])
            clusters.append(
                EmpiricalCluster(
                    cluster_id=cluster_id,
                    observations=cluster_atoms,
                    claimant_theory_ids=[],
                    relations=cluster_rels,
                )
            )

        return clusters

    def _find_target_parameters(
        self,
        target_id: str,
        clusters: list[EmpiricalCluster],
        cluster_theory_params: dict[tuple[str, str], dict[str, Any]],
        current_theory_id: str,
    ) -> dict[str, Any]:
        """Find parameters postulated at a target node or its associated theories."""
        # Find which cluster target_id belongs to
        for cluster in clusters:
            for obs in cluster.observations:
                if obs.id == target_id:
                    # Check if current theory or an overlapping theory has projected parameters
                    for (c_id, t_id), params in cluster_theory_params.items():
                        if c_id == cluster.cluster_id:
                            return params

        # Fallback to current parameters if self-referential or not yet resolved
        return {}

event_emitter property

Centralized event emitter.

run(input, context) async

Run the Theoretical Enrichment and Tenability Evaluation post-processor.

Parameters

input : Phase4ArtifactsView Typed slice of upstream Phase 4/5 theory atoms and relations. context : ArtifactExecutionContext Execution run context and manifest tracking.

Returns

ArtifactCollection Collection of emitted TheoreticalEnrichmentArtifact envelopes.

Source code in packages/episteme-pipeline/episteme_pipeline/post_processing/theoretical_enrichment/runner.py
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
async def run(
    self, input: Phase4ArtifactsView, context: ArtifactExecutionContext
) -> ArtifactCollection:
    """Run the Theoretical Enrichment and Tenability Evaluation post-processor.

    Parameters
    ----------
    input : Phase4ArtifactsView
        Typed slice of upstream Phase 4/5 theory atoms and relations.
    context : ArtifactExecutionContext
        Execution run context and manifest tracking.

    Returns
    -------
    ArtifactCollection
        Collection of emitted TheoreticalEnrichmentArtifact envelopes.
    """
    logger.info(
        "Theoretical Enrichment: Beginning evaluation on %d atoms and %d relations",
        len(input.theory_atoms),
        len(input.theory_relations),
    )

    if not self.config.enabled:
        logger.info("Theoretical Enrichment is disabled in config. Skipping.")
        return ArtifactCollection([])

    # -------------------------------------------------------------
    # Stage 0: Dynamic Theory-Element Induction (Macro Scope)
    # -------------------------------------------------------------
    if not self.registry.all_theories() and self.config.induce_theories and self.inducer:
        logger.info("Theoretical Enrichment: Registry is unseeded. Running LLM Theory-Element induction...")
        induced = await self.inducer.induce_theories(
            atoms=input.theory_atoms,
            relations=input.theory_relations,
            schema=self.schema,
            max_theories=self.config.max_theories,
        )
        for theory in induced:
            self.registry.register(theory)
        logger.info(
            "Theoretical Enrichment: Successfully induced and registered %d theories: %s",
            len(induced),
            [t.theory_id for t in induced],
        )

    # -------------------------------------------------------------
    # Step 1: Cluster Mapping (Application Identification)
    # -------------------------------------------------------------
    clusters = self._identify_empirical_clusters(
        input.theory_atoms, input.theory_relations
    )
    logger.info(
        "Theoretical Enrichment: Identified %d empirical clusters (Intended Applications I)",
        len(clusters),
    )

    # Map each cluster to claiming theories
    for cluster in clusters:
        claimants = self.registry.match_claimants(cluster)
        if not claimants:
            # Default to all registered theories for evaluation
            claimants = [t.theory_id for t in self.registry.all_theories()]
        cluster.claimant_theory_ids = claimants

        self.event_emitter.emit(
            TheoreticalClusterIdentified(
                run_id=context.run_id,
                phase=self.name,
                cluster_id=cluster.cluster_id,
                observation_count=len(cluster.observations),
                claimant_theories=claimants,
            )
        )

    # -------------------------------------------------------------
    # Step 2: Domain-Specific Projections (Phi_spec)
    # Step 3: Local Tenability Calculation (TS_local)
    # Step 4: Global and Intertheoretical Consistency (GL)
    # -------------------------------------------------------------
    enrichment_artifacts: list[ArtifactEnvelope[TheoreticalEnrichmentArtifact]] = []
    cluster_theory_params: dict[tuple[str, str], dict[str, Any]] = {}
    atoms_to_update: list[TheoryAtom] = []
    relations_to_update: list[dict[str, Any]] = []

    atom_by_id = {a.id: a for a in input.theory_atoms}

    for cluster in clusters:
        for theory_id in cluster.claimant_theory_ids:
            theory = self.registry.get(theory_id)
            if not theory:
                continue

            # Step 2: Project cluster through the theory-specific lens (Phi_spec)
            if hasattr(self.projector, "aproject"):
                projected_params = await self.projector.aproject(cluster, theory)
            else:
                projected_params = self.projector.project(cluster, theory)
            cluster_theory_params[(cluster.cluster_id, theory_id)] = projected_params

            self.event_emitter.emit(
                TheoreticalParametersProjected(
                    run_id=context.run_id,
                    phase=self.name,
                    cluster_id=cluster.cluster_id,
                    theory_id=theory_id,
                    parameters=projected_params,
                )
            )

            # Step 3: Local Tenability Calculation (TS_local)
            ts_local, delta_star, local_anomalies = self.solver.solve_local_tenability(
                projected_params, theory
            )

            for anom in local_anomalies:
                self.event_emitter.emit(
                    TenabilityAnomalyDetected(
                        run_id=context.run_id,
                        phase=self.name,
                        element_id=cluster.cluster_id,
                        theory_id=theory_id,
                        score=ts_local,
                        reason=anom,
                    )
                )

            # Step 4: Edge Tenability Calculation (TS_edge) across cluster relations
            edge_scores: dict[str, float] = {}
            edge_anomalies: list[str] = []

            for rel in cluster.relations:
                rel_key = f"{rel.source_id}->{rel.relation_type}->{rel.target_id}"

                # Look up counterpart params (either target node's cluster or sibling theory)
                target_params = self._find_target_parameters(
                    rel.target_id, clusters, cluster_theory_params, theory_id
                )

                ts_edge, delta_c, anoms = self.solver.solve_edge_tenability(
                    source_params=projected_params,
                    target_params=target_params,
                    relation=rel,
                )
                edge_scores[rel_key] = ts_edge
                edge_anomalies.extend(anoms)

                for anom in anoms:
                    self.event_emitter.emit(
                        TenabilityAnomalyDetected(
                            run_id=context.run_id,
                            phase=self.name,
                            element_id=rel_key,
                            theory_id=theory_id,
                            score=ts_edge,
                            reason=anom,
                        )
                    )

                # Stage relation for graph update
                rel_copy = rel.model_copy()
                rel_copy.tenability = ts_edge
                relations_to_update.append(
                    {
                        "from_id": rel.source_id,
                        "relation_type": rel.relation_type,
                        "to_id": rel.target_id,
                        "properties": {
                            "confidence": rel.confidence,
                            "scope": rel.scope,
                            "tenability": ts_edge,
                            "weight": rel.weight,
                        },
                    }
                )

            tenability_res = self.solver.evaluate_theory_tenability(
                local_score=ts_local,
                edge_scores=edge_scores,
                delta_star=delta_star,
                local_anomalies=local_anomalies,
                edge_anomalies=edge_anomalies,
            )

            self.event_emitter.emit(
                TenabilityEvaluationCompleted(
                    run_id=context.run_id,
                    phase=self.name,
                    theory_id=theory_id,
                    local_score=ts_local,
                    aggregated_score=tenability_res.aggregated_score,
                    is_tenable=tenability_res.is_tenable,
                    tightest_blur=delta_star,
                )
            )

            # Attach parameters and scores to TheoreticalHypothesis nodes in the cluster
            for obs in cluster.observations:
                if self.schema.component_partitions.get(obs.component_type) == "A":
                    obs_updated = obs.model_copy()
                    obs_updated.parameters.update(projected_params)
                    obs_updated.tenability = tenability_res
                    atoms_to_update.append(obs_updated)

            enrichment_payload = TheoreticalEnrichmentArtifact(
                enrichment_id=f"enrichment-{uuid4()}",
                cluster_id=cluster.cluster_id,
                theory_id=theory_id,
                projected_parameters=projected_params,
                local_tenability=ts_local,
                edge_tenabilities=edge_scores,
                aggregated_tenability=tenability_res.aggregated_score,
                admissible_blur_delta=delta_star,
                is_tenable=tenability_res.is_tenable,
                anomalies=tenability_res.anomalies,
            )

            envelope = ArtifactEnvelope[TheoreticalEnrichmentArtifact](
                artifact_id=enrichment_payload.enrichment_id,
                kind=ArtifactKind.THEORETICAL_ENRICHMENT,
                run_id=context.run_id,
                phase_name=self.name,
                method="theoretical_enrichment",
                payload=enrichment_payload,
                provenance=ArtifactProvenance(
                    source_chunk_id=cluster.observations[0].source_chunk_id
                    if cluster.observations
                    else None
                ),
            )
            enrichment_artifacts.append(envelope)

    # -------------------------------------------------------------
    # Step 5: Graph Persistence
    # -------------------------------------------------------------
    if self.graph_store:
        from itertools import batched

        if atoms_to_update:
            for batch in batched(atoms_to_update, 500):
                await self.graph_store.upsert_argument_components(list(batch))

        if relations_to_update:
            for batch in batched(relations_to_update, 500):
                await self.graph_store.upsert_relations(list(batch))

        logger.info(
            "Theoretical Enrichment: Projected %d enriched TheoryAtoms and %d relations to graph store",
            len(atoms_to_update),
            len(relations_to_update),
        )

    return ArtifactCollection(enrichment_artifacts)

episteme_pipeline.post_processing.theoretical_enrichment.inducer.LLMTheoryInducer

LLM-backed inducer for discovering Theory-Elements from epistemic graphs.

Parameters

llm : Any Underlying LLM facade or StructuredLLM instance. prompts : StructuredPromptBundle | str | None, optional Custom prompt bundle or template string, by default None. strategy : Any, optional Decoding strategy for structured prediction, by default "direct_constrained".

Source code in packages/episteme-pipeline/episteme_pipeline/post_processing/theoretical_enrichment/inducer.py
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
class LLMTheoryInducer:
    """LLM-backed inducer for discovering Theory-Elements from epistemic graphs.

    Parameters
    ----------
    llm : Any
        Underlying LLM facade or StructuredLLM instance.
    prompts : StructuredPromptBundle | str | None, optional
        Custom prompt bundle or template string, by default None.
    strategy : Any, optional
        Decoding strategy for structured prediction, by default "direct_constrained".
    """

    def __init__(
        self,
        llm: Any,
        prompts: StructuredPromptBundle | str | None = None,
        strategy: Any = "direct_constrained",
    ) -> None:
        self.llm: StructuredLLM = ensure_structured_llm(llm)
        self.prompts = prompts or THEORY_INDUCTION_DIRECT_PROMPT
        self.strategy = strategy

    async def induce_theories(
        self,
        atoms: list[TheoryAtom],
        relations: list[TheoryRelation],
        schema: SchemaConfig | None = None,
        max_theories: int = 5,
    ) -> list[TheoryElementDefinition]:
        """Induce Theory-Elements using structured LLM prediction.

        Parameters
        ----------
        atoms : list[TheoryAtom]
            Input graph nodes across partitions A and B.
        relations : list[TheoryRelation]
            Structural relations connecting atoms.
        schema : SchemaConfig | None, optional
            Graph schema defining component partitions, by default None.
        max_theories : int, optional
            Upper bound on induced theories, by default 5.

        Returns
        -------
        list[TheoryElementDefinition]
            List of induced formal Theory-Element specifications.
        """
        graph_schema = schema or DEFAULT_SCHEMA

        # Partition atoms into Theoretical (A) and Empirical (B)
        hypotheses: list[TheoryAtom] = []
        observations: list[TheoryAtom] = []

        for atom in atoms:
            part = graph_schema.component_partitions.get(atom.component_type, "")
            if part == "A" or "hypothesis" in atom.component_type.lower() or "claim" in atom.component_type.lower():
                hypotheses.append(atom)
            else:
                observations.append(atom)

        if not hypotheses:
            logger.info("LLMTheoryInducer: No TheoreticalHypothesis nodes found to induce theories from.")
            return []

        # Format prompt context
        hyp_lines = [f"- [{h.id}] {h.text}" for h in hypotheses[:30]]
        hyp_str = "\n".join(hyp_lines) if hyp_lines else "None identified."

        obs_lines = []
        for o in observations[:30]:
            dims = [f"{m.dimension}={m.value}" for m in o.measurements]
            dim_str = f" (measurements: {', '.join(dims)})" if dims else ""
            obs_lines.append(f"- [{o.id}] {o.text}{dim_str}")
        obs_str = "\n".join(obs_lines) if obs_lines else "None identified."

        rel_lines = [
            f"- {r.source_id} -[{r.relation_type}]-> {r.target_id} (conf={r.confidence})"
            for r in relations[:40]
        ]
        rel_str = "\n".join(rel_lines) if rel_lines else "None identified."

        logger.info(
            "LLMTheoryInducer: Prompting LLM on %d hypotheses and %d observations...",
            len(hypotheses),
            len(observations),
        )

        try:
            raw_output: TheoryInductionOutput = await self.llm.predict_structured(
                TheoryInductionOutput,
                self.prompts,
                strategy=self.strategy,
                theoretical_hypotheses=hyp_str,
                empirical_observations=obs_str,
                structural_relations=rel_str,
                max_theories=max_theories,
            )
        except Exception as err:
            logger.error("LLMTheoryInducer failed structured induction: %s", err)
            return []

        definitions: list[TheoryElementDefinition] = []
        for item in raw_output.theories:
            laws = [
                TheoryLaw(
                    law_id=law.law_id,
                    description=law.description,
                    formula_expression=law.formula_expression,
                    involved_parameters=law.involved_parameters,
                )
                for law in item.laws
            ]
            theory_def = TheoryElementDefinition(
                theory_id=item.theory_id,
                name=item.name,
                required_dimensions=item.required_dimensions,
                parameter_names=item.parameter_names,
                laws=laws,
                max_admissible_blur=item.max_admissible_blur,
                claimant_hypothesis_ids=item.claimant_hypothesis_ids,
            )
            definitions.append(theory_def)

        logger.info(
            "LLMTheoryInducer: Successfully induced %d Theory-Elements: %s",
            len(definitions),
            [d.theory_id for d in definitions],
        )
        return definitions

induce_theories(atoms, relations, schema=None, max_theories=5) async

Induce Theory-Elements using structured LLM prediction.

Parameters

atoms : list[TheoryAtom] Input graph nodes across partitions A and B. relations : list[TheoryRelation] Structural relations connecting atoms. schema : SchemaConfig | None, optional Graph schema defining component partitions, by default None. max_theories : int, optional Upper bound on induced theories, by default 5.

Returns

list[TheoryElementDefinition] List of induced formal Theory-Element specifications.

Source code in packages/episteme-pipeline/episteme_pipeline/post_processing/theoretical_enrichment/inducer.py
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
async def induce_theories(
    self,
    atoms: list[TheoryAtom],
    relations: list[TheoryRelation],
    schema: SchemaConfig | None = None,
    max_theories: int = 5,
) -> list[TheoryElementDefinition]:
    """Induce Theory-Elements using structured LLM prediction.

    Parameters
    ----------
    atoms : list[TheoryAtom]
        Input graph nodes across partitions A and B.
    relations : list[TheoryRelation]
        Structural relations connecting atoms.
    schema : SchemaConfig | None, optional
        Graph schema defining component partitions, by default None.
    max_theories : int, optional
        Upper bound on induced theories, by default 5.

    Returns
    -------
    list[TheoryElementDefinition]
        List of induced formal Theory-Element specifications.
    """
    graph_schema = schema or DEFAULT_SCHEMA

    # Partition atoms into Theoretical (A) and Empirical (B)
    hypotheses: list[TheoryAtom] = []
    observations: list[TheoryAtom] = []

    for atom in atoms:
        part = graph_schema.component_partitions.get(atom.component_type, "")
        if part == "A" or "hypothesis" in atom.component_type.lower() or "claim" in atom.component_type.lower():
            hypotheses.append(atom)
        else:
            observations.append(atom)

    if not hypotheses:
        logger.info("LLMTheoryInducer: No TheoreticalHypothesis nodes found to induce theories from.")
        return []

    # Format prompt context
    hyp_lines = [f"- [{h.id}] {h.text}" for h in hypotheses[:30]]
    hyp_str = "\n".join(hyp_lines) if hyp_lines else "None identified."

    obs_lines = []
    for o in observations[:30]:
        dims = [f"{m.dimension}={m.value}" for m in o.measurements]
        dim_str = f" (measurements: {', '.join(dims)})" if dims else ""
        obs_lines.append(f"- [{o.id}] {o.text}{dim_str}")
    obs_str = "\n".join(obs_lines) if obs_lines else "None identified."

    rel_lines = [
        f"- {r.source_id} -[{r.relation_type}]-> {r.target_id} (conf={r.confidence})"
        for r in relations[:40]
    ]
    rel_str = "\n".join(rel_lines) if rel_lines else "None identified."

    logger.info(
        "LLMTheoryInducer: Prompting LLM on %d hypotheses and %d observations...",
        len(hypotheses),
        len(observations),
    )

    try:
        raw_output: TheoryInductionOutput = await self.llm.predict_structured(
            TheoryInductionOutput,
            self.prompts,
            strategy=self.strategy,
            theoretical_hypotheses=hyp_str,
            empirical_observations=obs_str,
            structural_relations=rel_str,
            max_theories=max_theories,
        )
    except Exception as err:
        logger.error("LLMTheoryInducer failed structured induction: %s", err)
        return []

    definitions: list[TheoryElementDefinition] = []
    for item in raw_output.theories:
        laws = [
            TheoryLaw(
                law_id=law.law_id,
                description=law.description,
                formula_expression=law.formula_expression,
                involved_parameters=law.involved_parameters,
            )
            for law in item.laws
        ]
        theory_def = TheoryElementDefinition(
            theory_id=item.theory_id,
            name=item.name,
            required_dimensions=item.required_dimensions,
            parameter_names=item.parameter_names,
            laws=laws,
            max_admissible_blur=item.max_admissible_blur,
            claimant_hypothesis_ids=item.claimant_hypothesis_ids,
        )
        definitions.append(theory_def)

    logger.info(
        "LLMTheoryInducer: Successfully induced %d Theory-Elements: %s",
        len(definitions),
        [d.theory_id for d in definitions],
    )
    return definitions

episteme_pipeline.post_processing.theoretical_enrichment.projectors.LLMTheoryProjector

Bases: TheoryProjector

LLM-backed projector estimating empirical dimensions and theoretical parameters.

Prompts the LLM to project an empirical cluster through the specific formal lens of a Theory-Element, extracting observed dimension values and postulating candidate latent parameter values (M_p).

Parameters

llm : Any Underlying LLM facade or StructuredLLM instance. prompts : StructuredPromptBundle | str | None, optional Custom prompt bundle or template string, by default None. strategy : Any, optional Decoding strategy for structured prediction, by default "direct_constrained".

Source code in packages/episteme-pipeline/episteme_pipeline/post_processing/theoretical_enrichment/projectors.py
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
class LLMTheoryProjector(TheoryProjector):
    """LLM-backed projector estimating empirical dimensions and theoretical parameters.

    Prompts the LLM to project an empirical cluster through the specific formal lens
    of a Theory-Element, extracting observed dimension values and postulating
    candidate latent parameter values (M_p).

    Parameters
    ----------
    llm : Any
        Underlying LLM facade or StructuredLLM instance.
    prompts : StructuredPromptBundle | str | None, optional
        Custom prompt bundle or template string, by default None.
    strategy : Any, optional
        Decoding strategy for structured prediction, by default "direct_constrained".
    """

    def __init__(
        self,
        llm: Any,
        prompts: StructuredPromptBundle | str | None = None,
        strategy: Any = "direct_constrained",
    ) -> None:
        self.llm: StructuredLLM = ensure_structured_llm(llm)
        self.prompts = prompts or CLUSTER_PROJECTION_DIRECT_PROMPT
        self.strategy = strategy
        self._fallback = GenericTheoryProjector()

    async def aproject(
        self, cluster: EmpiricalCluster, theory: TheoryElementDefinition
    ) -> dict[str, Any]:
        """Asynchronously project empirical cluster into theoretical parameters via LLM.

        Parameters
        ----------
        cluster : EmpiricalCluster
            Target empirical cluster.
        theory : TheoryElementDefinition
            Theory-element definition specifying M_pp and M_p.

        Returns
        -------
        dict[str, Any]
            Dictionary of parameter name to postulated value.
        """
        # Fast path: check if cluster has exact structured measurements matching all parameters
        fast = self._try_measurement_fast_path(cluster, theory)
        if fast and len(fast) == len(theory.parameter_names):
            return fast

        obs_lines = []
        for o in cluster.observations:
            dims = [f"{m.dimension}={m.value} {m.unit}" for m in o.measurements]
            dim_str = f" [measurements: {', '.join(dims)}]" if dims else ""
            obs_lines.append(f"- [{o.id}] ({o.component_type}) {o.text}{dim_str}")
        obs_str = "\n".join(obs_lines) if obs_lines else "No observations."

        laws_str = "; ".join([f"{law.law_id}: {law.description}" for law in theory.laws]) or "None"

        try:
            raw: ClusterProjectionOutput = await self.llm.predict_structured(
                ClusterProjectionOutput,
                self.prompts,
                strategy=self.strategy,
                theory_name=theory.name,
                required_dimensions=", ".join(theory.required_dimensions) or "None specified",
                parameter_names=", ".join(theory.parameter_names) or "None specified",
                theory_laws=laws_str,
                cluster_observations=obs_str,
            )
            result = dict(raw.projected_parameters)
            for p in theory.parameter_names:
                if p not in result:
                    result[p] = 0.5
            return result
        except Exception as err:
            logger.warning("LLMTheoryProjector failed structured projection: %s. Using fallback.", err)
            return self._fallback.project(cluster, theory)

    def project(
        self, cluster: EmpiricalCluster, theory: TheoryElementDefinition
    ) -> dict[str, Any]:
        """Synchronous projection wrapper using fast-path or fallback."""
        fast = self._try_measurement_fast_path(cluster, theory)
        if fast:
            return fast
        return self._fallback.project(cluster, theory)

    def _try_measurement_fast_path(
        self, cluster: EmpiricalCluster, theory: TheoryElementDefinition
    ) -> dict[str, Any]:
        """Extract parameters if structured measurements directly match."""
        params: dict[str, Any] = {}
        for i, param in enumerate(theory.parameter_names):
            p_clean = param.lower().replace("_", "")
            tokens = [t for t in re.findall(r"[a-z]+", param.lower()) if len(t) > 3]
            vals: list[float] = []
            for obs in cluster.observations:
                for m in obs.measurements:
                    m_clean = m.dimension.lower().replace("_", "")
                    if m_clean in p_clean or p_clean in m_clean or any(t in m_clean for t in tokens):
                        if isinstance(m.value, (int, float)):
                            vals.append(float(m.value))
                    elif i < len(theory.required_dimensions):
                        req_dim = theory.required_dimensions[i].lower().replace("_", "")
                        if req_dim in m_clean or m_clean in req_dim:
                            if isinstance(m.value, (int, float)):
                                vals.append(float(m.value))
            if vals:
                params[param] = round(sum(vals) / len(vals), 3)
        return params

aproject(cluster, theory) async

Asynchronously project empirical cluster into theoretical parameters via LLM.

Parameters

cluster : EmpiricalCluster Target empirical cluster. theory : TheoryElementDefinition Theory-element definition specifying M_pp and M_p.

Returns

dict[str, Any] Dictionary of parameter name to postulated value.

Source code in packages/episteme-pipeline/episteme_pipeline/post_processing/theoretical_enrichment/projectors.py
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
async def aproject(
    self, cluster: EmpiricalCluster, theory: TheoryElementDefinition
) -> dict[str, Any]:
    """Asynchronously project empirical cluster into theoretical parameters via LLM.

    Parameters
    ----------
    cluster : EmpiricalCluster
        Target empirical cluster.
    theory : TheoryElementDefinition
        Theory-element definition specifying M_pp and M_p.

    Returns
    -------
    dict[str, Any]
        Dictionary of parameter name to postulated value.
    """
    # Fast path: check if cluster has exact structured measurements matching all parameters
    fast = self._try_measurement_fast_path(cluster, theory)
    if fast and len(fast) == len(theory.parameter_names):
        return fast

    obs_lines = []
    for o in cluster.observations:
        dims = [f"{m.dimension}={m.value} {m.unit}" for m in o.measurements]
        dim_str = f" [measurements: {', '.join(dims)}]" if dims else ""
        obs_lines.append(f"- [{o.id}] ({o.component_type}) {o.text}{dim_str}")
    obs_str = "\n".join(obs_lines) if obs_lines else "No observations."

    laws_str = "; ".join([f"{law.law_id}: {law.description}" for law in theory.laws]) or "None"

    try:
        raw: ClusterProjectionOutput = await self.llm.predict_structured(
            ClusterProjectionOutput,
            self.prompts,
            strategy=self.strategy,
            theory_name=theory.name,
            required_dimensions=", ".join(theory.required_dimensions) or "None specified",
            parameter_names=", ".join(theory.parameter_names) or "None specified",
            theory_laws=laws_str,
            cluster_observations=obs_str,
        )
        result = dict(raw.projected_parameters)
        for p in theory.parameter_names:
            if p not in result:
                result[p] = 0.5
        return result
    except Exception as err:
        logger.warning("LLMTheoryProjector failed structured projection: %s. Using fallback.", err)
        return self._fallback.project(cluster, theory)

project(cluster, theory)

Synchronous projection wrapper using fast-path or fallback.

Source code in packages/episteme-pipeline/episteme_pipeline/post_processing/theoretical_enrichment/projectors.py
182
183
184
185
186
187
188
189
def project(
    self, cluster: EmpiricalCluster, theory: TheoryElementDefinition
) -> dict[str, Any]:
    """Synchronous projection wrapper using fast-path or fallback."""
    fast = self._try_measurement_fast_path(cluster, theory)
    if fast:
        return fast
    return self._fallback.project(cluster, theory)

episteme_pipeline.post_processing.theoretical_enrichment.solvers.SafeFormulaEvaluator

Safe AST-based evaluator for induced mathematical constraint laws.

Evaluates symbolic arithmetic and functional constraints without using Python's eval() function, preventing code injection and syntax crashes.

Source code in packages/episteme-pipeline/episteme_pipeline/post_processing/theoretical_enrichment/solvers.py
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
class SafeFormulaEvaluator:
    """Safe AST-based evaluator for induced mathematical constraint laws.

    Evaluates symbolic arithmetic and functional constraints without using Python's
    eval() function, preventing code injection and syntax crashes.
    """

    _ALLOWED_CALLS = {
        "abs": abs,
        "min": min,
        "max": max,
        "sqrt": lambda x: math.sqrt(max(0.0, float(x))),
    }

    @classmethod
    def evaluate(cls, expression: str, parameters: dict[str, Any]) -> float:
        """Evaluate a constraint expression and return deviation >= 0.0.

        Parameters
        ----------
        expression : str
            Symbolic formula (e.g. 'abs(dopamine - amygdala) * 0.5').
        parameters : dict[str, Any]
            Dictionary of parameter names to numeric values.

        Returns
        -------
        float
            Computed deviation (0.0 represents exact satisfaction).
        """
        if not expression or not expression.strip():
            return 0.0

        try:
            tree = ast.parse(expression.strip(), mode="eval")
            param_lower = {k.lower(): float(v) for k, v in parameters.items() if isinstance(v, (int, float))}
            val = cls._eval_node(tree.body, param_lower, parameters)
            return abs(float(val))
        except Exception as err:
            logger.warning("SafeFormulaEvaluator failed on '%s': %s", expression, err)
            return 1.0

    @classmethod
    def _eval_node(cls, node: ast.AST, param_lower: dict[str, float], raw_params: dict[str, Any]) -> float:
        if isinstance(node, ast.Constant):
            if isinstance(node.value, (int, float)):
                return float(node.value)
            raise ValueError(f"Unsupported constant type: {type(node.value)}")

        if isinstance(node, ast.Name):
            name = node.id
            if name in raw_params and isinstance(raw_params[name], (int, float)):
                return float(raw_params[name])
            if name.lower() in param_lower:
                return param_lower[name.lower()]
            return 0.0

        if isinstance(node, ast.UnaryOp):
            operand = cls._eval_node(node.operand, param_lower, raw_params)
            if isinstance(node.op, ast.UAdd):
                return +operand
            if isinstance(node.op, ast.USub):
                return -operand
            raise ValueError(f"Unsupported unary operator: {type(node.op)}")

        if isinstance(node, ast.BinOp):
            left = cls._eval_node(node.left, param_lower, raw_params)
            right = cls._eval_node(node.right, param_lower, raw_params)
            if isinstance(node.op, ast.Add):
                return left + right
            if isinstance(node.op, ast.Sub):
                return left - right
            if isinstance(node.op, ast.Mult):
                return left * right
            if isinstance(node.op, ast.Div):
                return left / right if right != 0.0 else 1.0
            if isinstance(node.op, ast.Pow):
                return left ** right
            raise ValueError(f"Unsupported binary operator: {type(node.op)}")

        if isinstance(node, ast.Call):
            func_name = getattr(node.func, "id", None)
            if func_name in cls._ALLOWED_CALLS:
                args = [cls._eval_node(arg, param_lower, raw_params) for arg in node.args]
                return float(cls._ALLOWED_CALLS[func_name](*args))
            raise ValueError(f"Unsupported function call: {func_name}")

        if isinstance(node, ast.Compare):
            left = cls._eval_node(node.left, param_lower, raw_params)
            if len(node.comparators) == 1 and len(node.ops) == 1:
                right = cls._eval_node(node.comparators[0], param_lower, raw_params)
                op = node.ops[0]
                if isinstance(op, ast.Eq):
                    return abs(left - right)
                if isinstance(op, (ast.Lt, ast.LtE)):
                    return max(0.0, left - right)
                if isinstance(op, (ast.Gt, ast.GtE)):
                    return max(0.0, right - left)
            raise ValueError("Unsupported comparison expression")

        raise ValueError(f"Unsupported AST node: {type(node)}")

evaluate(expression, parameters) classmethod

Evaluate a constraint expression and return deviation >= 0.0.

Parameters

expression : str Symbolic formula (e.g. 'abs(dopamine - amygdala) * 0.5'). parameters : dict[str, Any] Dictionary of parameter names to numeric values.

Returns

float Computed deviation (0.0 represents exact satisfaction).

Source code in packages/episteme-pipeline/episteme_pipeline/post_processing/theoretical_enrichment/solvers.py
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
@classmethod
def evaluate(cls, expression: str, parameters: dict[str, Any]) -> float:
    """Evaluate a constraint expression and return deviation >= 0.0.

    Parameters
    ----------
    expression : str
        Symbolic formula (e.g. 'abs(dopamine - amygdala) * 0.5').
    parameters : dict[str, Any]
        Dictionary of parameter names to numeric values.

    Returns
    -------
    float
        Computed deviation (0.0 represents exact satisfaction).
    """
    if not expression or not expression.strip():
        return 0.0

    try:
        tree = ast.parse(expression.strip(), mode="eval")
        param_lower = {k.lower(): float(v) for k, v in parameters.items() if isinstance(v, (int, float))}
        val = cls._eval_node(tree.body, param_lower, parameters)
        return abs(float(val))
    except Exception as err:
        logger.warning("SafeFormulaEvaluator failed on '%s': %s", expression, err)
        return 1.0

episteme_pipeline.post_processing.theoretical_enrichment.enrichment_models.TheoryRegistry

Registry holding active Theory-Element definitions.

Parameters

seed_theories : list[TheoryElementDefinition] | None, optional Initial list of theory elements to populate the registry with.

Source code in packages/episteme-pipeline/episteme_pipeline/post_processing/theoretical_enrichment/enrichment_models.py
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
class TheoryRegistry:
    """Registry holding active Theory-Element definitions.

    Parameters
    ----------
    seed_theories : list[TheoryElementDefinition] | None, optional
        Initial list of theory elements to populate the registry with.
    """

    def __init__(
        self, seed_theories: list[TheoryElementDefinition] | None = None
    ) -> None:
        self._theories: dict[str, TheoryElementDefinition] = {}
        if seed_theories:
            for theory in seed_theories:
                self.register(theory)

    def register(self, theory: TheoryElementDefinition) -> None:
        """Register a new Theory-Element definition.

        Parameters
        ----------
        theory : TheoryElementDefinition
            Theory-element specification to register.
        """
        self._theories[theory.theory_id] = theory

    def register_induced_theories(
        self, induced: list[InducedTheoryElement]
    ) -> list[TheoryElementDefinition]:
        """Convert and register dynamically induced Theory-Elements.

        Parameters
        ----------
        induced : list[InducedTheoryElement]
            List of induced theory elements from LLM induction.

        Returns
        -------
        list[TheoryElementDefinition]
            The instantiated and registered TheoryElementDefinition objects.
        """
        registered: list[TheoryElementDefinition] = []
        for item in induced:
            laws = [
                TheoryLaw(
                    law_id=law.law_id,
                    description=law.description,
                    formula_expression=law.formula_expression,
                    involved_parameters=law.involved_parameters,
                )
                for law in item.laws
            ]
            theory_def = TheoryElementDefinition(
                theory_id=item.theory_id,
                name=item.name,
                required_dimensions=item.required_dimensions,
                parameter_names=item.parameter_names,
                laws=laws,
                max_admissible_blur=item.max_admissible_blur,
                claimant_hypothesis_ids=item.claimant_hypothesis_ids,
            )
            self.register(theory_def)
            registered.append(theory_def)
        return registered

    def get(self, theory_id: str) -> TheoryElementDefinition | None:
        """Retrieve a registered Theory-Element by id.

        Parameters
        ----------
        theory_id : str
            Identifier of the theory.

        Returns
        -------
        TheoryElementDefinition | None
            The registered theory definition, or None if not found.
        """
        return self._theories.get(theory_id)

    def all_theories(self) -> list[TheoryElementDefinition]:
        """Return all registered theory definitions.

        Returns
        -------
        list[TheoryElementDefinition]
            List of registered theory definitions.
        """
        return list(self._theories.values())

    def match_claimants(self, cluster: EmpiricalCluster) -> list[str]:
        """Identify which registered theories claim an empirical cluster.

        A theory claims a cluster if:
        1. Any of the theory's required dimensions are present in the cluster measurements, OR
        2. Any observation unit text mentions the theory keywords or concepts, OR
        3. Any cluster node is registered as a claimant hypothesis ID for the theory.

        Parameters
        ----------
        cluster : EmpiricalCluster
            Target empirical cluster.

        Returns
        -------
        list[str]
            List of matching theory_id strings.
        """
        claimants: list[str] = []
        cluster_dims = cluster.available_dimensions
        cluster_texts = " ".join(obs.text.lower() for obs in cluster.observations)
        cluster_node_ids = {obs.id for obs in cluster.observations}

        for tid, theory in self._theories.items():
            req_set = {d.lower() for d in theory.required_dimensions}
            if req_set and not req_set.isdisjoint(cluster_dims):
                claimants.append(tid)
                continue
            if tid in cluster_texts or theory.name.lower() in cluster_texts:
                claimants.append(tid)
                continue
            if set(theory.claimant_hypothesis_ids).intersection(cluster_node_ids):
                claimants.append(tid)
                continue

        return claimants

all_theories()

Return all registered theory definitions.

Returns

list[TheoryElementDefinition] List of registered theory definitions.

Source code in packages/episteme-pipeline/episteme_pipeline/post_processing/theoretical_enrichment/enrichment_models.py
314
315
316
317
318
319
320
321
322
def all_theories(self) -> list[TheoryElementDefinition]:
    """Return all registered theory definitions.

    Returns
    -------
    list[TheoryElementDefinition]
        List of registered theory definitions.
    """
    return list(self._theories.values())

get(theory_id)

Retrieve a registered Theory-Element by id.

Parameters

theory_id : str Identifier of the theory.

Returns

TheoryElementDefinition | None The registered theory definition, or None if not found.

Source code in packages/episteme-pipeline/episteme_pipeline/post_processing/theoretical_enrichment/enrichment_models.py
299
300
301
302
303
304
305
306
307
308
309
310
311
312
def get(self, theory_id: str) -> TheoryElementDefinition | None:
    """Retrieve a registered Theory-Element by id.

    Parameters
    ----------
    theory_id : str
        Identifier of the theory.

    Returns
    -------
    TheoryElementDefinition | None
        The registered theory definition, or None if not found.
    """
    return self._theories.get(theory_id)

match_claimants(cluster)

Identify which registered theories claim an empirical cluster.

A theory claims a cluster if: 1. Any of the theory's required dimensions are present in the cluster measurements, OR 2. Any observation unit text mentions the theory keywords or concepts, OR 3. Any cluster node is registered as a claimant hypothesis ID for the theory.

Parameters

cluster : EmpiricalCluster Target empirical cluster.

Returns

list[str] List of matching theory_id strings.

Source code in packages/episteme-pipeline/episteme_pipeline/post_processing/theoretical_enrichment/enrichment_models.py
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
def match_claimants(self, cluster: EmpiricalCluster) -> list[str]:
    """Identify which registered theories claim an empirical cluster.

    A theory claims a cluster if:
    1. Any of the theory's required dimensions are present in the cluster measurements, OR
    2. Any observation unit text mentions the theory keywords or concepts, OR
    3. Any cluster node is registered as a claimant hypothesis ID for the theory.

    Parameters
    ----------
    cluster : EmpiricalCluster
        Target empirical cluster.

    Returns
    -------
    list[str]
        List of matching theory_id strings.
    """
    claimants: list[str] = []
    cluster_dims = cluster.available_dimensions
    cluster_texts = " ".join(obs.text.lower() for obs in cluster.observations)
    cluster_node_ids = {obs.id for obs in cluster.observations}

    for tid, theory in self._theories.items():
        req_set = {d.lower() for d in theory.required_dimensions}
        if req_set and not req_set.isdisjoint(cluster_dims):
            claimants.append(tid)
            continue
        if tid in cluster_texts or theory.name.lower() in cluster_texts:
            claimants.append(tid)
            continue
        if set(theory.claimant_hypothesis_ids).intersection(cluster_node_ids):
            claimants.append(tid)
            continue

    return claimants

register(theory)

Register a new Theory-Element definition.

Parameters

theory : TheoryElementDefinition Theory-element specification to register.

Source code in packages/episteme-pipeline/episteme_pipeline/post_processing/theoretical_enrichment/enrichment_models.py
250
251
252
253
254
255
256
257
258
def register(self, theory: TheoryElementDefinition) -> None:
    """Register a new Theory-Element definition.

    Parameters
    ----------
    theory : TheoryElementDefinition
        Theory-element specification to register.
    """
    self._theories[theory.theory_id] = theory

register_induced_theories(induced)

Convert and register dynamically induced Theory-Elements.

Parameters

induced : list[InducedTheoryElement] List of induced theory elements from LLM induction.

Returns

list[TheoryElementDefinition] The instantiated and registered TheoryElementDefinition objects.

Source code in packages/episteme-pipeline/episteme_pipeline/post_processing/theoretical_enrichment/enrichment_models.py
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
def register_induced_theories(
    self, induced: list[InducedTheoryElement]
) -> list[TheoryElementDefinition]:
    """Convert and register dynamically induced Theory-Elements.

    Parameters
    ----------
    induced : list[InducedTheoryElement]
        List of induced theory elements from LLM induction.

    Returns
    -------
    list[TheoryElementDefinition]
        The instantiated and registered TheoryElementDefinition objects.
    """
    registered: list[TheoryElementDefinition] = []
    for item in induced:
        laws = [
            TheoryLaw(
                law_id=law.law_id,
                description=law.description,
                formula_expression=law.formula_expression,
                involved_parameters=law.involved_parameters,
            )
            for law in item.laws
        ]
        theory_def = TheoryElementDefinition(
            theory_id=item.theory_id,
            name=item.name,
            required_dimensions=item.required_dimensions,
            parameter_names=item.parameter_names,
            laws=laws,
            max_admissible_blur=item.max_admissible_blur,
            claimant_hypothesis_ids=item.claimant_hypothesis_ids,
        )
        self.register(theory_def)
        registered.append(theory_def)
    return registered

episteme_pipeline.post_processing.theoretical_enrichment.solvers.TenabilitySolver

Solver for local and intertheoretical tenability optimization.

Parameters

anomaly_threshold : float, optional Threshold below which scores are flagged as anomalies, by default 0.5. weight_local : float, optional Relative weight for local law satisfaction, by default 0.5. weight_edge : float, optional Relative weight for intertheoretical / constraint consistency, by default 0.5.

Source code in packages/episteme-pipeline/episteme_pipeline/post_processing/theoretical_enrichment/solvers.py
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
class TenabilitySolver:
    """Solver for local and intertheoretical tenability optimization.

    Parameters
    ----------
    anomaly_threshold : float, optional
        Threshold below which scores are flagged as anomalies, by default 0.5.
    weight_local : float, optional
        Relative weight for local law satisfaction, by default 0.5.
    weight_edge : float, optional
        Relative weight for intertheoretical / constraint consistency, by default 0.5.
    """

    def __init__(
        self,
        anomaly_threshold: float = 0.5,
        weight_local: float = 0.5,
        weight_edge: float = 0.5,
    ) -> None:
        self.anomaly_threshold = anomaly_threshold
        total_w = weight_local + weight_edge
        self.weight_local = weight_local / total_w if total_w > 0 else 0.5
        self.weight_edge = weight_edge / total_w if total_w > 0 else 0.5

    def solve_local_tenability(
        self,
        parameters: dict[str, Any],
        theory: TheoryElementDefinition,
    ) -> tuple[float, float, list[str]]:
        """Calculate the tightest admissible blur delta* and TS_local score.

        Parameters
        ----------
        parameters : dict[str, Any]
            The postulated theoretical parameters.
        theory : TheoryElementDefinition
            Target theory definition with its core laws M.

        Returns
        -------
        tuple[float, float, list[str]]
            Tuple of (TS_local, delta_star, anomalies).
        """
        if not theory.laws:
            return 1.0, 0.0, []

        deviations: list[float] = []
        anomalies: list[str] = []

        for law in theory.laws:
            dev = law.compute_deviation(parameters)
            deviations.append(dev)
            if dev > theory.max_admissible_blur * self.anomaly_threshold:
                anomalies.append(
                    f"Law '{law.law_id}' deviation {dev:.3f} exceeds threshold "
                    f"under parameters {parameters}"
                )

        delta_star = max(deviations) if deviations else 0.0
        ts_local = max(0.0, min(1.0, 1.0 - (delta_star / theory.max_admissible_blur)))

        if ts_local < self.anomaly_threshold:
            anomalies.append(
                f"Theory '{theory.name}' exhibits low local tenability (TS_local = {ts_local:.3f} < {self.anomaly_threshold})"
            )

        return round(ts_local, 3), round(delta_star, 3), anomalies

    def solve_edge_tenability(
        self,
        source_params: dict[str, Any],
        target_params: dict[str, Any],
        relation: TheoryRelation,
    ) -> tuple[float, float, list[str]]:
        """Evaluate constraint or intertheoretical consistency across an edge.

        Under constraint blurs v_{delta_C}, calculates:
            TS_edge(e) = sup { 1 - delta_C | (Phi(y_a), Phi(y_b)) in v_{delta_C} }

        Parameters
        ----------
        source_params : dict[str, Any]
            Parameters postulated at the source theory/cluster.
        target_params : dict[str, Any]
            Parameters postulated at the target theory/cluster.
        relation : TheoryRelation
            The intertheoretical or constraint relation.

        Returns
        -------
        tuple[float, float, list[str]]
            Tuple of (TS_edge, delta_c, anomalies).
        """
        rel_type = relation.relation_type.upper()
        anomalies: list[str] = []
        deltas: list[float] = []

        shared_keys = set(source_params.keys()) & set(target_params.keys())
        for k in shared_keys:
            val_s = source_params[k]
            val_t = target_params[k]
            if isinstance(val_s, (int, float)) and isinstance(val_t, (int, float)):
                diff = abs(float(val_s) - float(val_t))
                deltas.append(diff)

        if not shared_keys:
            repression = source_params.get("RepressionMagnitude") or target_params.get("RepressionMagnitude")
            amygdala = source_params.get("AmygdalaHyperactivity") or target_params.get("AmygdalaHyperactivity")
            dopamine = source_params.get("DopamineDepletion") or target_params.get("DopamineDepletion")

            if repression is not None and amygdala is not None:
                diff = abs(float(repression) - float(amygdala))
                deltas.append(diff)
            elif repression is not None and dopamine is not None:
                diff = abs(float(repression) - float(dopamine))
                deltas.append(diff)

        delta_c = max(deltas) if deltas else 0.0
        ts_edge = max(0.0, min(1.0, 1.0 - delta_c))

        if ts_edge < self.anomaly_threshold:
            anomalies.append(
                f"Constraint edge '{rel_type}' between {relation.source_id} and {relation.target_id} "
                f"is untenable (TS_edge = {ts_edge:.3f}, delta_C = {delta_c:.3f})"
            )

        return round(ts_edge, 3), round(delta_c, 3), anomalies

    def evaluate_theory_tenability(
        self,
        local_score: float,
        edge_scores: dict[str, float],
        delta_star: float,
        local_anomalies: list[str],
        edge_anomalies: list[str],
    ) -> TenabilityResult:
        """Combine local and edge scores into a final TenabilityResult.

        Parameters
        ----------
        local_score : float
            Local law adherence score (TS_local).
        edge_scores : dict[str, float]
            Map of relation_id -> TS_edge.
        delta_star : float
            Tightest admissible blur found.
        local_anomalies : list[str]
            Local law violation notices.
        edge_anomalies : list[str]
            Constraint violation notices.

        Returns
        -------
        TenabilityResult
            Complete aggregated result.
        """
        avg_edge = (
            sum(edge_scores.values()) / len(edge_scores)
            if edge_scores
            else 1.0
        )

        aggregated = round(
            (self.weight_local * local_score) + (self.weight_edge * avg_edge), 3
        )
        is_tenable = aggregated >= self.anomaly_threshold
        all_anomalies = local_anomalies + edge_anomalies

        return TenabilityResult(
            local_score=local_score,
            edge_scores=edge_scores,
            aggregated_score=aggregated,
            is_tenable=is_tenable,
            tightest_blur=delta_star,
            anomalies=all_anomalies,
        )

evaluate_theory_tenability(local_score, edge_scores, delta_star, local_anomalies, edge_anomalies)

Combine local and edge scores into a final TenabilityResult.

Parameters

local_score : float Local law adherence score (TS_local). edge_scores : dict[str, float] Map of relation_id -> TS_edge. delta_star : float Tightest admissible blur found. local_anomalies : list[str] Local law violation notices. edge_anomalies : list[str] Constraint violation notices.

Returns

TenabilityResult Complete aggregated result.

Source code in packages/episteme-pipeline/episteme_pipeline/post_processing/theoretical_enrichment/solvers.py
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
def evaluate_theory_tenability(
    self,
    local_score: float,
    edge_scores: dict[str, float],
    delta_star: float,
    local_anomalies: list[str],
    edge_anomalies: list[str],
) -> TenabilityResult:
    """Combine local and edge scores into a final TenabilityResult.

    Parameters
    ----------
    local_score : float
        Local law adherence score (TS_local).
    edge_scores : dict[str, float]
        Map of relation_id -> TS_edge.
    delta_star : float
        Tightest admissible blur found.
    local_anomalies : list[str]
        Local law violation notices.
    edge_anomalies : list[str]
        Constraint violation notices.

    Returns
    -------
    TenabilityResult
        Complete aggregated result.
    """
    avg_edge = (
        sum(edge_scores.values()) / len(edge_scores)
        if edge_scores
        else 1.0
    )

    aggregated = round(
        (self.weight_local * local_score) + (self.weight_edge * avg_edge), 3
    )
    is_tenable = aggregated >= self.anomaly_threshold
    all_anomalies = local_anomalies + edge_anomalies

    return TenabilityResult(
        local_score=local_score,
        edge_scores=edge_scores,
        aggregated_score=aggregated,
        is_tenable=is_tenable,
        tightest_blur=delta_star,
        anomalies=all_anomalies,
    )

solve_edge_tenability(source_params, target_params, relation)

Evaluate constraint or intertheoretical consistency across an edge.

Under constraint blurs v_{delta_C}, calculates: TS_edge(e) = sup { 1 - delta_C | (Phi(y_a), Phi(y_b)) in v_{delta_C} }

Parameters

source_params : dict[str, Any] Parameters postulated at the source theory/cluster. target_params : dict[str, Any] Parameters postulated at the target theory/cluster. relation : TheoryRelation The intertheoretical or constraint relation.

Returns

tuple[float, float, list[str]] Tuple of (TS_edge, delta_c, anomalies).

Source code in packages/episteme-pipeline/episteme_pipeline/post_processing/theoretical_enrichment/solvers.py
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
def solve_edge_tenability(
    self,
    source_params: dict[str, Any],
    target_params: dict[str, Any],
    relation: TheoryRelation,
) -> tuple[float, float, list[str]]:
    """Evaluate constraint or intertheoretical consistency across an edge.

    Under constraint blurs v_{delta_C}, calculates:
        TS_edge(e) = sup { 1 - delta_C | (Phi(y_a), Phi(y_b)) in v_{delta_C} }

    Parameters
    ----------
    source_params : dict[str, Any]
        Parameters postulated at the source theory/cluster.
    target_params : dict[str, Any]
        Parameters postulated at the target theory/cluster.
    relation : TheoryRelation
        The intertheoretical or constraint relation.

    Returns
    -------
    tuple[float, float, list[str]]
        Tuple of (TS_edge, delta_c, anomalies).
    """
    rel_type = relation.relation_type.upper()
    anomalies: list[str] = []
    deltas: list[float] = []

    shared_keys = set(source_params.keys()) & set(target_params.keys())
    for k in shared_keys:
        val_s = source_params[k]
        val_t = target_params[k]
        if isinstance(val_s, (int, float)) and isinstance(val_t, (int, float)):
            diff = abs(float(val_s) - float(val_t))
            deltas.append(diff)

    if not shared_keys:
        repression = source_params.get("RepressionMagnitude") or target_params.get("RepressionMagnitude")
        amygdala = source_params.get("AmygdalaHyperactivity") or target_params.get("AmygdalaHyperactivity")
        dopamine = source_params.get("DopamineDepletion") or target_params.get("DopamineDepletion")

        if repression is not None and amygdala is not None:
            diff = abs(float(repression) - float(amygdala))
            deltas.append(diff)
        elif repression is not None and dopamine is not None:
            diff = abs(float(repression) - float(dopamine))
            deltas.append(diff)

    delta_c = max(deltas) if deltas else 0.0
    ts_edge = max(0.0, min(1.0, 1.0 - delta_c))

    if ts_edge < self.anomaly_threshold:
        anomalies.append(
            f"Constraint edge '{rel_type}' between {relation.source_id} and {relation.target_id} "
            f"is untenable (TS_edge = {ts_edge:.3f}, delta_C = {delta_c:.3f})"
        )

    return round(ts_edge, 3), round(delta_c, 3), anomalies

solve_local_tenability(parameters, theory)

Calculate the tightest admissible blur delta* and TS_local score.

Parameters

parameters : dict[str, Any] The postulated theoretical parameters. theory : TheoryElementDefinition Target theory definition with its core laws M.

Returns

tuple[float, float, list[str]] Tuple of (TS_local, delta_star, anomalies).

Source code in packages/episteme-pipeline/episteme_pipeline/post_processing/theoretical_enrichment/solvers.py
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
def solve_local_tenability(
    self,
    parameters: dict[str, Any],
    theory: TheoryElementDefinition,
) -> tuple[float, float, list[str]]:
    """Calculate the tightest admissible blur delta* and TS_local score.

    Parameters
    ----------
    parameters : dict[str, Any]
        The postulated theoretical parameters.
    theory : TheoryElementDefinition
        Target theory definition with its core laws M.

    Returns
    -------
    tuple[float, float, list[str]]
        Tuple of (TS_local, delta_star, anomalies).
    """
    if not theory.laws:
        return 1.0, 0.0, []

    deviations: list[float] = []
    anomalies: list[str] = []

    for law in theory.laws:
        dev = law.compute_deviation(parameters)
        deviations.append(dev)
        if dev > theory.max_admissible_blur * self.anomaly_threshold:
            anomalies.append(
                f"Law '{law.law_id}' deviation {dev:.3f} exceeds threshold "
                f"under parameters {parameters}"
            )

    delta_star = max(deviations) if deviations else 0.0
    ts_local = max(0.0, min(1.0, 1.0 - (delta_star / theory.max_admissible_blur)))

    if ts_local < self.anomaly_threshold:
        anomalies.append(
            f"Theory '{theory.name}' exhibits low local tenability (TS_local = {ts_local:.3f} < {self.anomaly_threshold})"
        )

    return round(ts_local, 3), round(delta_star, 3), anomalies

Programmatic Seeding of Custom Theories

While the runner dynamically induces theories via LLM by default, custom theories can be registered explicitly:

from pipeline.post_processing.theoretical_enrichment import (
    TheoryElementDefinition,
    TheoryLaw,
    TheoryRegistry,
    TheoreticalEnrichmentRunner,
)

# 1. Define a core law with symbolic formula or callable evaluator
law = TheoryLaw(
    law_id="newton_second_law",
    description="Force equals mass times acceleration.",
    formula_expression="abs(Force - Mass * Acceleration)",
    involved_parameters=["Force", "Mass", "Acceleration"],
)

# 2. Define the Theory-Element
classical_mechanics = TheoryElementDefinition(
    theory_id="classical_mechanics",
    name="Classical Mechanics",
    required_dimensions=["position", "time"],
    parameter_names=["Mass", "Force", "Acceleration"],
    laws=[law],
    max_admissible_blur=1.0,
)

# 3. Seed the registry
registry = TheoryRegistry(seed_theories=[classical_mechanics])
runner = TheoreticalEnrichmentRunner(registry=registry)