Skip to content

API reference

Every public function and the Theory class, in the groups the R package's reference index uses and in the same order, so a name is found in the same place on either site. The R package prefixes each name with tf_, so check() here is tf_check() there.

The four functions whose name matches their module (diagram, dossier, implications, simulate) are documented by their canonical path, so that the function rather than the module is shown.

Core IO

Read, validate and write theory objects. Theory is also the builder: adding a construct, a proposition or a prediction, and logging the provenance of each, are methods on the class here, where the R package exposes them as tf_add_* functions.

theoryforge.Theory

A theory as a versioned, machine-checkable object.

Wraps the parsed mapping (self.data); all accessors tolerate missing optional collections by treating them as empty. The builder methods (add_* and set_formal_model) change the theory in place and return it, so calls chain. copy() gives an independent copy to amend.

Source code in src/theoryforge/core.py
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
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
class Theory:
    """A theory as a versioned, machine-checkable object.

    Wraps the parsed mapping (``self.data``); all accessors tolerate missing
    optional collections by treating them as empty. The builder methods
    (``add_*`` and ``set_formal_model``) change the theory in place and return
    it, so calls chain. ``copy()`` gives an independent copy to amend.
    """

    def __init__(self, data: dict):
        if not isinstance(data, dict):
            raise TypeError("Theory data must be a mapping")
        self.data = data

    # -- convenience accessors -------------------------------------------------
    @property
    def id(self) -> str:
        return self.data.get("id", "")

    @property
    def maturity(self) -> str:
        return self.data.get("maturity", "")

    def _list(self, key: str) -> list:
        return _items(self.data, key)

    # -- validation ------------------------------------------------------------
    def validate(self, *, full: bool = False) -> bool:
        """Check the theory against the schema, ``theory.schema.json``.

        The default pass checks structure. It covers the required top-level
        fields, the ``maturity`` and ``theory_form`` enums, the top-level field
        names and the required fields and enums of each construct, proposition
        and prediction. It also checks that every collection is a list. A field
        that is absent, null or blank is reported as missing, and one that holds
        another type as such (``id must be a string``).

        With ``full=True`` it also checks that every id is unique within its
        collection and that every cross-reference (proposition endpoints,
        prediction derivations and diagnostics, assumption, evidence and
        test-outcome targets) points to a declared id. It then checks the rest
        of the schema. That covers the required fields of assumptions,
        alternatives, evidence and test outcomes, a boolean ``passed``, the
        evidence direction and formal-model type enums, the version block and
        the ``schema_version`` pattern. It also covers numbers within [0, 1]
        where the schema asks for them, a prediction's ``bounds`` and ``test``
        block, a test outcome's ``estimate`` and ``se``, and the type of every
        other field and of every entry of a string array.

        CI compares the result with a JSON Schema 2020-12 validator. Besides the
        ids and references, which the schema cannot express, the pass is
        stricter in two ways, since a blank string counts as missing and an
        entry of a string array must be a nonempty string. It is more lenient
        in three ways, which API_SPEC.md section 2 documents. A null optional
        field is absent and a single string stands for a one-element array of
        strings. An empty sequence and an empty mapping stand for each other.

        Returns:
            True when the theory passes.

        Raises:
            ValueError: ``"invalid theory object: "`` followed by every problem
                found, joined by ``"; "``, in the order API_SPEC.md section 2
                fixes. The R twin's ``tf_validate()`` gives the same message.
        """
        errors: list[str] = []
        d = self.data
        for req in ("schema_version", "id", "title", "maturity"):
            errors += _required_text(d.get(req), f"missing/empty required field: {req}", req)
        # Each enum test asks whether the value is a nonempty string before
        # asking whether it is a member. A YAML list or mapping reaches these
        # lines whenever a field is mistyped, and `x in <set>` raises TypeError
        # on an unhashable value, which would abandon the collected errors and
        # report something the contract never promised. The string test also
        # keeps the twins level: R's `%in%` coerces a one-element list to its
        # element, so `theory_form: [network]` used to validate there and be
        # refused here.
        mat = d.get("maturity")
        if not (_nonempty_str(mat) and mat in _MATURITY):
            errors.append(f"maturity must be one of {', '.join(sorted(_MATURITY))}")
        if "theory_form" in d:
            form = d["theory_form"]
            if not (_nonempty_str(form) and form in _FORM):
                errors.append(f"theory_form must be one of {', '.join(sorted(_FORM))}")
        # A misspelt collection key would otherwise pass silently and quietly
        # change every downstream verdict (a renamed `predictions:` drops the
        # whole collection), so unrecognised top-level fields are refused.
        known = _resources.theory_schema().get("properties", {})
        for key in d:
            if key not in known:
                errors.append(f"unknown top-level field: {key}")
        # A collection that is not a list reads as empty (API_SPEC.md section 3),
        # so the theory would lose it without a word.
        for key in _COLLECTIONS:
            if _not_a_list(d.get(key)):
                errors.append(f"{key} must be a list")
        for i, c in enumerate(self._list("constructs")):
            errors += _required_fields(c, f"construct[{i}]", ("id", "label", "definition"))
        for i, p in enumerate(self._list("propositions")):
            errors += _required_fields(p, f"proposition[{i}]", ("id", "from", "to", "relation"))
            rel = _field(p, "relation")
            if _nonempty_str(rel) and rel not in _RELATION:
                errors.append(f"proposition[{i}] relation '{rel}' not allowed")
        for i, p in enumerate(self._list("predictions")):
            errors += _required_fields(p, f"prediction[{i}]", ("id", "statement", "type"))
            ty = _field(p, "type")
            if _nonempty_str(ty) and ty not in _PRED_TYPE:
                errors.append(f"prediction[{i}] type '{ty}' not allowed")

        if full:
            self._full_errors(errors)

        if errors:
            raise ValueError("invalid theory object: " + "; ".join(errors))
        return True

    def _full_errors(self, errors: list[str]) -> None:
        """Append the problems only ``validate(full=True)`` looks for.

        Items 1 to 14 of API_SPEC.md section 2, in that order. The R twin's
        ``tf_validate()`` makes the same checks in the same order with the same
        message text.
        """
        d = self.data
        cons = self._list("constructs")
        props = self._list("propositions")
        preds = self._list("predictions")
        alts = self._list("alternatives")
        auxs = self._list("auxiliary_assumptions")
        evs = self._list("evidence")
        tos = self._list("test_outcomes")

        def ids_of(items: list) -> set:
            return {i for i in (_field(it, "id") for it in items) if _nonempty_str(i)}

        construct_ids = ids_of(cons)
        proposition_ids = ids_of(props)
        prediction_ids = ids_of(preds)
        alternative_ids = ids_of(alts)

        def dups(items: list, kind: str) -> None:
            seen: set = set()
            for it in items:
                i = _field(it, "id")
                if _nonempty_str(i):
                    if i in seen:
                        errors.append(f"duplicate {kind} id: {i}")
                    seen.add(i)

        dups(cons, "construct")
        dups(props, "proposition")
        dups(preds, "prediction")
        dups(alts, "alternative")
        dups(auxs, "assumption")
        for i, p in enumerate(props):
            frm, to = _field(p, "from"), _field(p, "to")
            if _nonempty_str(frm) and frm not in construct_ids:
                errors.append(f"proposition[{i}] from '{frm}' is not a known construct")
            if _nonempty_str(to) and to not in construct_ids:
                errors.append(f"proposition[{i}] to '{to}' is not a known construct")
        # Items 3 and 4: each entry of a referencing array is either a nonempty
        # string to look up or a problem in its own right (item 10).
        for i, p in enumerate(preds):
            errors += _string_array(_field(p, "derives_from"), f"prediction[{i}] derives_from",
                                    proposition_ids, "proposition")
            errors += _string_array(_field(p, "diagnostic_vs"), f"prediction[{i}] diagnostic_vs",
                                    alternative_ids, "alternative")
        for i, a in enumerate(auxs):
            errors += _string_array(_field(a, "protects"), f"assumption[{i}] protects",
                                    prediction_ids, "prediction")
        for i, t in enumerate(tos):
            pid = _field(t, "prediction_id")
            if _nonempty_str(pid) and pid not in prediction_ids:
                errors.append(f"test_outcome[{i}] prediction_id '{pid}' is not a known prediction")
        for i, e in enumerate(evs):
            s = _field(e, "supports")
            if _nonempty_str(s) and s not in prediction_ids:
                errors.append(f"evidence[{i}] supports '{s}' is not a known prediction")
        # The schema types prediction severity as a number in [0, 1]; enforced
        # here so a file cannot pass full validation and then be refused by
        # the scorer, which rejects non-numeric severities.
        for i, p in enumerate(preds):
            s = _field(p, "severity")
            if s is not None and not _is_unit_number(s):
                errors.append(f"prediction[{i}] severity must be a number between 0 and 1")

        # 8: the required fields of the other collections. A quoted "true" in
        # passed read as a failure and turned a progressive amendment into a
        # degenerating one, so passed must be a boolean.
        for i, a in enumerate(auxs):
            errors += _required_fields(a, f"assumption[{i}]", ("id", "statement"))
        for i, a in enumerate(alts):
            errors += _required_fields(a, f"alternative[{i}]", ("id", "label"))
        for i, e in enumerate(evs):
            errors += _required_fields(e, f"evidence[{i}]", ("supports", "direction"))
            direction = _field(e, "direction")
            if _nonempty_str(direction) and direction not in _DIRECTION:
                errors.append(f"evidence[{i}] direction '{direction}' not allowed")
        for i, t in enumerate(tos):
            errors += _required_fields(t, f"test_outcome[{i}]", ("prediction_id",))
            if not isinstance(_field(t, "passed"), bool):
                errors.append(f"test_outcome[{i}] passed must be true or false")

        # 9: typed optional fields of assumptions and test outcomes.
        for i, a in enumerate(auxs):
            errors += _optional_text(_field(a, "added_for"), f"assumption[{i}] added_for",
                                     nullable=True)
        for i, t in enumerate(tos):
            errors += _unit_number(_field(t, "severity_at_test"), f"test_outcome[{i}] severity_at_test")
            errors += _optional_text(_field(t, "registered"), f"test_outcome[{i}] registered",
                                     nullable=True)
            errors += _optional_text(_field(t, "date"), f"test_outcome[{i}] date", nullable=True)

        # 11: the formal model. A type outside the enum earned the formalisation
        # point in 0.6.0, and reads as absent since (API_SPEC.md section 3).
        fm = d.get("formal_model")
        if fm is not None and _not_a_mapping(fm):
            errors.append("formal_model must be a mapping")
        elif isinstance(fm, dict):
            ty = fm.get("type")
            if ty is not None and not isinstance(ty, str):
                errors.append(_not_string("formal_model type", ty))
            elif ty is not None and ty not in _FORMAL_MODEL_TYPE:
                errors.append(f"formal_model type '{ty}' not allowed")
            errors += _optional_text(fm.get("spec_ref"), "formal_model spec_ref", nullable=True)

        # 12: the version block, which the schema closes.
        ver = d.get("version")
        if ver is not None and _not_a_mapping(ver):
            errors.append("version must be a mapping")
        elif isinstance(ver, dict):
            for key in ver:
                if key not in _VERSION_KEYS:
                    errors.append(f"version has unknown field: {key}")
            errors += _optional_text(ver.get("id"), "version id")
            errors += _optional_text(ver.get("parent_id"), "version parent_id", nullable=True)
            errors += _optional_text(ver.get("content_hash"), "version content_hash", nullable=True)

        # 13: the schema_version pattern. fullmatch, because "$" in a Python
        # pattern also matches before a final newline.
        sv = d.get("schema_version")
        if isinstance(sv, str) and _nonempty_str(sv) and not _SCHEMA_VERSION.fullmatch(sv):
            errors.append('schema_version must match major.minor (for example "1.0")')

        # 14: the schema's remaining types, collection by collection in the
        # schema's order.
        for i, c in enumerate(cons):
            errors += _string_array(_field(c, "measurement"), f"construct[{i}] measurement")
            errors += _string_array(_field(c, "boundary_conditions"),
                                    f"construct[{i}] boundary_conditions")
        for i, p in enumerate(props):
            errors += _optional_text(_field(p, "mechanism"), f"proposition[{i}] mechanism")
            errors += _optional_text(_field(p, "functional_form"), f"proposition[{i}] functional_form")
        errors += _string_array(d.get("boundary_conditions"), "boundary_conditions")
        for i, p in enumerate(preds):
            errors += _unit_number(_field(p, "risk_score"), f"prediction[{i}] risk_score")
            errors += _bounds(_field(p, "bounds"), f"prediction[{i}] bounds")
            errors += _test_plan(_field(p, "test"), f"prediction[{i}] test")
        for i, e in enumerate(evs):
            errors += _optional_text(_field(e, "source_doi"), f"evidence[{i}] source_doi", nullable=True)
        for i, t in enumerate(tos):
            errors += _optional_text(_field(t, "observed"), f"test_outcome[{i}] observed")
            errors += _estimate_and_se(t, f"test_outcome[{i}]")
        for i, a in enumerate(alts):
            errors += _string_array(_field(a, "key_constructs"), f"alternative[{i}] key_constructs")
            errors += _optional_text(_field(a, "source_doi"), f"alternative[{i}] source_doi",
                                     nullable=True)
        for i, step in enumerate(self._list("provenance")):
            if _not_a_mapping(step):
                errors.append(f"provenance[{i}] must be a mapping")
                continue
            for name in ("step", "action", "detail"):
                errors += _optional_text(_field(step, name), f"provenance[{i}] {name}")

    # -- serialisation ---------------------------------------------------------
    def write(self, path) -> None:
        """Write the theory to ``path`` as JSON (a ``.json`` suffix) or YAML (anything else).

        The file is UTF-8 with LF line endings and ends with a newline. YAML keeps
        the theory's key order and quotes every string a reader could take for
        something else, ``y``, ``Y``, ``n`` and ``N`` included. The file therefore
        reads back as the same theory in both twins, and in theoryforge 0.6.0 for R
        (API_SPEC.md section 3, "Reading and writing files").
        """
        path = Path(path)
        if path.suffix.lower() == ".json":
            text = json.dumps(self.data, indent=2, ensure_ascii=False) + "\n"
        else:
            text = _dump_yaml(self.data)
        _write_lf(path, text)

    # -- builder (BUILDING mode) ----------------------------------------------
    # The builders change self.data in place and return self, where the R
    # builders return a modified copy (API_SPEC.md section 8).
    def copy(self) -> Theory:
        """An independent copy of the theory, to amend while the original stays as it is.

        The builders work in place, so after ``v2 = v1.add_prediction(...)`` the
        names ``v2`` and ``v1`` refer to one object. Appraising one against the
        other then compares a theory with itself. Beginning the amendment with
        ``v2 = v1.copy()`` keeps ``v1`` intact. The copy is deep, since
        ``Theory(dict(v1.data))`` would copy only the top-level mapping and the
        two objects would still share their constructs, propositions and
        predictions.

        Returns:
            A new ``Theory`` holding a deep copy of ``self.data``.
        """
        return Theory(deepcopy(self.data))

    def _coll(self, key: str) -> list:
        """The list a builder appends to under ``key``.

        A missing key gives a new empty list, and so does a null one. A template
        that leaves ``constructs:`` blank reads as null, and ``setdefault`` would
        hand that None back. The caller stores the list once it has appended to
        it. Any other value that is not a list is refused with a message that
        names the key.
        """
        v = self.data.get(key)
        if v is None:
            return []
        if not isinstance(v, list):
            kind = type(v).__name__
            # "an int" and "an OrderedDict", but "a UUID": a type name that
            # opens with a u is read with a consonant sound.
            article = "an" if kind[:1].lower() in "aeio" else "a"
            raise TypeError(
                f"cannot add to '{key}': the theory holds {article} {kind} there, not a list")
        return v

    def _provenance(self, action: str, detail: str) -> None:
        prov = self._coll("provenance")
        prov.append({"step": str(len(prov) + 1), "action": action, "detail": detail})
        self.data["provenance"] = prov

    def _add(self, key: str, item: dict, action: str, detail: str) -> Theory:
        """Append ``item`` to the collection under ``key`` and log the step.

        The provenance log is checked before the item is stored, so a call
        that is refused adds nothing to the theory.
        """
        coll = self._coll(key)
        self._coll("provenance")
        coll.append(item)
        self.data[key] = coll
        self._provenance(action, detail)
        return self

    def add_construct(self, id, label, definition, measurement=None, boundary_conditions=None):
        c = {"id": id, "label": label, "definition": definition}
        if measurement is not None:
            c["measurement"] = _as_str_list(measurement)
        if boundary_conditions is not None:
            c["boundary_conditions"] = _as_str_list(boundary_conditions)
        return self._add("constructs", c, "tf_add_construct", id)

    def add_proposition(self, id, frm, to, relation, mechanism=None):
        p = {"id": id, "from": frm, "to": to, "relation": relation}
        if mechanism is not None:
            p["mechanism"] = mechanism
        return self._add("propositions", p, "tf_add_proposition", id)

    def add_prediction(self, id, statement, type, derives_from=None, diagnostic_vs=None,
                       severity=None, test=None):
        """Append a prediction and log the step in the provenance.

        ``id`` identifies the prediction and ``statement`` gives the claim in
        words. ``type`` is the form of the claim, one of four. ``existence``
        asserts that an effect or relation exists, without a direction.
        ``directional`` asserts a sign or an order, including comparisons,
        interactions, the invariance of a direction across groups and claims that
        an effect occurs only when a condition holds. ``interval`` asserts that a
        quantity lies in a stated range, the range the theory permits. ``point``
        asserts one value, with the tolerance that measurement requires. That
        width is measurement tolerance, not latitude the theory allows. The label
        is self-declared, and no function checks it against the statement.

        ``derives_from`` holds the ids of the propositions the prediction derives
        from, and ``diagnostic_vs`` the ids of the registered alternatives it
        would discriminate from. ``severity`` is a declared pre-data severity
        from 0 to 1, stored only when given. The checklist's risk_severity item
        reads it, and reads the claim-form rubric's value for a prediction that
        declares none. The dossier sets the two side by side.

        ``test`` is the sampling plan for testing the prediction, a mapping of
        any of ``n_planned``, ``unit``, ``alpha``, ``sided`` (``"two"`` or
        ``"one"``), ``sesoi`` and ``sesoi_scale`` (the smallest effect of
        interest and its scale), ``power`` or ``decision_probability`` with an
        optional ``power_lo`` and ``power_hi``, ``method``, ``design_ref`` and
        ``justification`` (API_SPEC.md section 30). It is stored, as a copy,
        only when given. ``preregister()`` lists it after the hypotheses, and
        nothing scores it. The theory is returned, so that builder calls chain.
        """
        p = {"id": id, "statement": statement, "type": type}
        if derives_from is not None:
            p["derives_from"] = _as_str_list(derives_from)
        if diagnostic_vs is not None:
            p["diagnostic_vs"] = _as_str_list(diagnostic_vs)
        if severity is not None:
            p["severity"] = severity
        if test is not None:
            # A copy, so that a later change to the caller's mapping leaves the
            # theory as it was, as R's builder leaves it.
            p["test"] = dict(test) if isinstance(test, Mapping) else test
        return self._add("predictions", p, "tf_add_prediction", id)

    def add_alternative(self, id, label, key_constructs=None):
        a = {"id": id, "label": label}
        if key_constructs is not None:
            a["key_constructs"] = _as_str_list(key_constructs)
        return self._add("alternatives", a, "tf_add_alternative", id)

    def add_assumption(self, id, statement, added_for=None, protects=None):
        """Append an auxiliary assumption and log the step in the provenance.

        ``added_for`` is the id of the prediction whose anomaly the assumption
        was added to answer, and None marks a core assumption.
        ``appraise_amendment`` counts an assumption added for an anomaly as ad
        hoc unless a prediction it protects that is new in the amended version,
        other than this one, is corroborated. ``protects`` holds the ids of the
        predictions the assumption shields from refutation, a list or a single
        string. The theory is returned, so that builder calls chain.
        """
        a = {"id": id, "statement": statement, "added_for": added_for}
        if protects is not None:
            a["protects"] = _as_str_list(protects)
        return self._add("auxiliary_assumptions", a, "tf_add_assumption", id)

    def set_formal_model(self, type, spec_ref=None):
        # The log is checked first, as in _add, so a refused call leaves the
        # formal model as it was.
        self._coll("provenance")
        self.data["formal_model"] = {"type": type, "spec_ref": spec_ref}
        self._provenance("tf_set_formal_model", type)
        return self

    # -- mirrored public API --------------------------------------------------
    def check(self) -> dict:
        return _check(self.data)

    def report(self, format: str = "json") -> str:
        return _report(self.data, format=format)

    def redundancy_check(self) -> list[dict]:
        return _redundancy_check(self.data)

    def diagram(self, type: str = "nomological_net", engine: str = "graphviz") -> str:
        """Return the diagram IR for ``type`` (one of nomological_net, provenance,
        causal_dag, development_roadmap, pipeline, context, workflow, venn, rigour,
        severity); see :func:`theoryforge.diagram.diagram`."""
        return _diagram(self.data, type=type, engine=engine)

    def render_diagram(self, type: str = "nomological_net"):
        """Render a digraph view via the optional ``graphviz`` library; see
        :func:`theoryforge.render.render_diagram`."""
        from .render import render_diagram as _render_diagram
        return _render_diagram(self.data, type=type)

    def severity(self) -> list[dict]:
        """Per-prediction risk and computed severity from the claim-form riskiness rubric."""
        return _severity(self.data)

    def post_data_severity(self) -> list[dict]:
        """How severely each bounded prediction passed its test, from the outcome's estimate and SE.

        See :func:`theoryforge.post_data_severity` for the rule and its refusals.
        """
        return _post_data_severity(self.data)

    def appraise_amendment(self, prior) -> dict:
        """Appraise this theory, by content, as an amendment of a prior version.

        The verdict is progressive, degenerating or neutral, with the evidence
        behind it. Passing this theory itself as the prior raises ValueError,
        so begin an amendment with ``prior.copy()`` (see
        :func:`theoryforge.appraise_amendment`).
        """
        return _appraise_amendment(self.data, prior)

    def implications(self, cycles: str = "refuse") -> dict:
        """The conditional independencies the theory's causal graph implies.

        ``cycles="sigma"`` reads a graph with feedback loops by
        sigma-separation, which the default refuses. See
        :func:`theoryforge.implications`.
        """
        return _implications(self.data, cycles=cycles)

    def preregister(self, path=None) -> str:
        """Render the hypotheses section of a preregistration, writing it to ``path``
        when one is given (see :func:`theoryforge.preregister`)."""
        return _preregister(self.data, path)

    def landscape(self, corpus, min_link: int = 2, max_token_share: float = 0.5,
                  method: str = "components") -> dict:
        """Map this theory and its alternatives onto a literature corpus's themes.

        See :func:`theoryforge.landscape` for the matching rules, statuses and methods.
        """
        return _landscape(self.data, corpus, min_link=min_link, max_token_share=max_token_share,
                          method=method)

    def new_evidence_dois(self, candidate_dois: list) -> list:
        """DOIs in `candidate_dois` not already cited by this theory's evidence or alternatives."""
        return _new_evidence_dois(self.data, candidate_dois)

    def compile_sem(self) -> str:
        """Compile constructs and propositions to lavaan model syntax.

        See :func:`theoryforge.compile_sem` for how names are written and when
        a collision is refused.
        """
        return _compile_sem(self.data)

    def dossier(self) -> str:
        """A reviewer-facing audit bundle of the rigour report, severity, test record, evidence,
        provenance and preregistration (see :func:`theoryforge.dossier.dossier`)."""
        return _dossier(self.data)

    def simulate(self, steps: int = 10, dt: float = 0.1, k: float = 1.0,
                 damping: float = 0.5, init: float = 1.0, method: str = "euler") -> dict:
        """Propagate the construct network as a linear dynamical system (see ``theoryforge.simulate``)."""
        return _simulate(self.data, steps=steps, dt=dt, k=k, damping=damping, init=init,
                         method=method)

    def embedding_redundancy(self, embedder, threshold=None) -> list[dict]:
        """An opt-in embedding-based redundancy screen; results depend on the supplied embedder."""
        return _embedding_redundancy(self.data, embedder, threshold=threshold)

    def render_report(self, path, title=None, render: bool = False, to: str = "html") -> str:
        """Write (and optionally render) a Quarto report of the audit dossier."""
        return _render_report(self.data, path, title=title, render=render, to=to)

    def osf_push(self, token=None, node=None, filename=None, dry_run: bool = True,
                 base_url: str | None = None, overwrite: bool = False) -> dict:
        """Deposit the dossier on OSF (dry-run by default). A live push needs a token and node.

        ``overwrite=True`` adds a new version of an existing file of the same name.
        """
        kw = {} if base_url is None else {"base_url": base_url}
        return _osf_push(self.data, token=token, node=node, filename=filename, dry_run=dry_run,
                         overwrite=overwrite, **kw)

    def __repr__(self) -> str:
        return f"Theory(id={self.id!r}, maturity={self.maturity!r})"

add_assumption(id, statement, added_for=None, protects=None)

Append an auxiliary assumption and log the step in the provenance.

added_for is the id of the prediction whose anomaly the assumption was added to answer, and None marks a core assumption. appraise_amendment counts an assumption added for an anomaly as ad hoc unless a prediction it protects that is new in the amended version, other than this one, is corroborated. protects holds the ids of the predictions the assumption shields from refutation, a list or a single string. The theory is returned, so that builder calls chain.

Source code in src/theoryforge/core.py
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
def add_assumption(self, id, statement, added_for=None, protects=None):
    """Append an auxiliary assumption and log the step in the provenance.

    ``added_for`` is the id of the prediction whose anomaly the assumption
    was added to answer, and None marks a core assumption.
    ``appraise_amendment`` counts an assumption added for an anomaly as ad
    hoc unless a prediction it protects that is new in the amended version,
    other than this one, is corroborated. ``protects`` holds the ids of the
    predictions the assumption shields from refutation, a list or a single
    string. The theory is returned, so that builder calls chain.
    """
    a = {"id": id, "statement": statement, "added_for": added_for}
    if protects is not None:
        a["protects"] = _as_str_list(protects)
    return self._add("auxiliary_assumptions", a, "tf_add_assumption", id)

add_prediction(id, statement, type, derives_from=None, diagnostic_vs=None, severity=None, test=None)

Append a prediction and log the step in the provenance.

id identifies the prediction and statement gives the claim in words. type is the form of the claim, one of four. existence asserts that an effect or relation exists, without a direction. directional asserts a sign or an order, including comparisons, interactions, the invariance of a direction across groups and claims that an effect occurs only when a condition holds. interval asserts that a quantity lies in a stated range, the range the theory permits. point asserts one value, with the tolerance that measurement requires. That width is measurement tolerance, not latitude the theory allows. The label is self-declared, and no function checks it against the statement.

derives_from holds the ids of the propositions the prediction derives from, and diagnostic_vs the ids of the registered alternatives it would discriminate from. severity is a declared pre-data severity from 0 to 1, stored only when given. The checklist's risk_severity item reads it, and reads the claim-form rubric's value for a prediction that declares none. The dossier sets the two side by side.

test is the sampling plan for testing the prediction, a mapping of any of n_planned, unit, alpha, sided ("two" or "one"), sesoi and sesoi_scale (the smallest effect of interest and its scale), power or decision_probability with an optional power_lo and power_hi, method, design_ref and justification (API_SPEC.md section 30). It is stored, as a copy, only when given. preregister() lists it after the hypotheses, and nothing scores it. The theory is returned, so that builder calls chain.

Source code in src/theoryforge/core.py
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
def add_prediction(self, id, statement, type, derives_from=None, diagnostic_vs=None,
                   severity=None, test=None):
    """Append a prediction and log the step in the provenance.

    ``id`` identifies the prediction and ``statement`` gives the claim in
    words. ``type`` is the form of the claim, one of four. ``existence``
    asserts that an effect or relation exists, without a direction.
    ``directional`` asserts a sign or an order, including comparisons,
    interactions, the invariance of a direction across groups and claims that
    an effect occurs only when a condition holds. ``interval`` asserts that a
    quantity lies in a stated range, the range the theory permits. ``point``
    asserts one value, with the tolerance that measurement requires. That
    width is measurement tolerance, not latitude the theory allows. The label
    is self-declared, and no function checks it against the statement.

    ``derives_from`` holds the ids of the propositions the prediction derives
    from, and ``diagnostic_vs`` the ids of the registered alternatives it
    would discriminate from. ``severity`` is a declared pre-data severity
    from 0 to 1, stored only when given. The checklist's risk_severity item
    reads it, and reads the claim-form rubric's value for a prediction that
    declares none. The dossier sets the two side by side.

    ``test`` is the sampling plan for testing the prediction, a mapping of
    any of ``n_planned``, ``unit``, ``alpha``, ``sided`` (``"two"`` or
    ``"one"``), ``sesoi`` and ``sesoi_scale`` (the smallest effect of
    interest and its scale), ``power`` or ``decision_probability`` with an
    optional ``power_lo`` and ``power_hi``, ``method``, ``design_ref`` and
    ``justification`` (API_SPEC.md section 30). It is stored, as a copy,
    only when given. ``preregister()`` lists it after the hypotheses, and
    nothing scores it. The theory is returned, so that builder calls chain.
    """
    p = {"id": id, "statement": statement, "type": type}
    if derives_from is not None:
        p["derives_from"] = _as_str_list(derives_from)
    if diagnostic_vs is not None:
        p["diagnostic_vs"] = _as_str_list(diagnostic_vs)
    if severity is not None:
        p["severity"] = severity
    if test is not None:
        # A copy, so that a later change to the caller's mapping leaves the
        # theory as it was, as R's builder leaves it.
        p["test"] = dict(test) if isinstance(test, Mapping) else test
    return self._add("predictions", p, "tf_add_prediction", id)

appraise_amendment(prior)

Appraise this theory, by content, as an amendment of a prior version.

The verdict is progressive, degenerating or neutral, with the evidence behind it. Passing this theory itself as the prior raises ValueError, so begin an amendment with prior.copy() (see :func:theoryforge.appraise_amendment).

Source code in src/theoryforge/core.py
749
750
751
752
753
754
755
756
757
def appraise_amendment(self, prior) -> dict:
    """Appraise this theory, by content, as an amendment of a prior version.

    The verdict is progressive, degenerating or neutral, with the evidence
    behind it. Passing this theory itself as the prior raises ValueError,
    so begin an amendment with ``prior.copy()`` (see
    :func:`theoryforge.appraise_amendment`).
    """
    return _appraise_amendment(self.data, prior)

compile_sem()

Compile constructs and propositions to lavaan model syntax.

See :func:theoryforge.compile_sem for how names are written and when a collision is refused.

Source code in src/theoryforge/core.py
786
787
788
789
790
791
792
def compile_sem(self) -> str:
    """Compile constructs and propositions to lavaan model syntax.

    See :func:`theoryforge.compile_sem` for how names are written and when
    a collision is refused.
    """
    return _compile_sem(self.data)

copy()

An independent copy of the theory, to amend while the original stays as it is.

The builders work in place, so after v2 = v1.add_prediction(...) the names v2 and v1 refer to one object. Appraising one against the other then compares a theory with itself. Beginning the amendment with v2 = v1.copy() keeps v1 intact. The copy is deep, since Theory(dict(v1.data)) would copy only the top-level mapping and the two objects would still share their constructs, propositions and predictions.

Returns:

Type Description
Theory

A new Theory holding a deep copy of self.data.

Source code in src/theoryforge/core.py
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
def copy(self) -> Theory:
    """An independent copy of the theory, to amend while the original stays as it is.

    The builders work in place, so after ``v2 = v1.add_prediction(...)`` the
    names ``v2`` and ``v1`` refer to one object. Appraising one against the
    other then compares a theory with itself. Beginning the amendment with
    ``v2 = v1.copy()`` keeps ``v1`` intact. The copy is deep, since
    ``Theory(dict(v1.data))`` would copy only the top-level mapping and the
    two objects would still share their constructs, propositions and
    predictions.

    Returns:
        A new ``Theory`` holding a deep copy of ``self.data``.
    """
    return Theory(deepcopy(self.data))

diagram(type='nomological_net', engine='graphviz')

Return the diagram IR for type (one of nomological_net, provenance, causal_dag, development_roadmap, pipeline, context, workflow, venn, rigour, severity); see :func:theoryforge.diagram.diagram.

Source code in src/theoryforge/core.py
726
727
728
729
730
def diagram(self, type: str = "nomological_net", engine: str = "graphviz") -> str:
    """Return the diagram IR for ``type`` (one of nomological_net, provenance,
    causal_dag, development_roadmap, pipeline, context, workflow, venn, rigour,
    severity); see :func:`theoryforge.diagram.diagram`."""
    return _diagram(self.data, type=type, engine=engine)

dossier()

A reviewer-facing audit bundle of the rigour report, severity, test record, evidence, provenance and preregistration (see :func:theoryforge.dossier.dossier).

Source code in src/theoryforge/core.py
794
795
796
797
def dossier(self) -> str:
    """A reviewer-facing audit bundle of the rigour report, severity, test record, evidence,
    provenance and preregistration (see :func:`theoryforge.dossier.dossier`)."""
    return _dossier(self.data)

embedding_redundancy(embedder, threshold=None)

An opt-in embedding-based redundancy screen; results depend on the supplied embedder.

Source code in src/theoryforge/core.py
805
806
807
def embedding_redundancy(self, embedder, threshold=None) -> list[dict]:
    """An opt-in embedding-based redundancy screen; results depend on the supplied embedder."""
    return _embedding_redundancy(self.data, embedder, threshold=threshold)

implications(cycles='refuse')

The conditional independencies the theory's causal graph implies.

cycles="sigma" reads a graph with feedback loops by sigma-separation, which the default refuses. See :func:theoryforge.implications.

Source code in src/theoryforge/core.py
759
760
761
762
763
764
765
766
def implications(self, cycles: str = "refuse") -> dict:
    """The conditional independencies the theory's causal graph implies.

    ``cycles="sigma"`` reads a graph with feedback loops by
    sigma-separation, which the default refuses. See
    :func:`theoryforge.implications`.
    """
    return _implications(self.data, cycles=cycles)

landscape(corpus, min_link=2, max_token_share=0.5, method='components')

Map this theory and its alternatives onto a literature corpus's themes.

See :func:theoryforge.landscape for the matching rules, statuses and methods.

Source code in src/theoryforge/core.py
773
774
775
776
777
778
779
780
def landscape(self, corpus, min_link: int = 2, max_token_share: float = 0.5,
              method: str = "components") -> dict:
    """Map this theory and its alternatives onto a literature corpus's themes.

    See :func:`theoryforge.landscape` for the matching rules, statuses and methods.
    """
    return _landscape(self.data, corpus, min_link=min_link, max_token_share=max_token_share,
                      method=method)

new_evidence_dois(candidate_dois)

DOIs in candidate_dois not already cited by this theory's evidence or alternatives.

Source code in src/theoryforge/core.py
782
783
784
def new_evidence_dois(self, candidate_dois: list) -> list:
    """DOIs in `candidate_dois` not already cited by this theory's evidence or alternatives."""
    return _new_evidence_dois(self.data, candidate_dois)

osf_push(token=None, node=None, filename=None, dry_run=True, base_url=None, overwrite=False)

Deposit the dossier on OSF (dry-run by default). A live push needs a token and node.

overwrite=True adds a new version of an existing file of the same name.

Source code in src/theoryforge/core.py
813
814
815
816
817
818
819
820
821
def osf_push(self, token=None, node=None, filename=None, dry_run: bool = True,
             base_url: str | None = None, overwrite: bool = False) -> dict:
    """Deposit the dossier on OSF (dry-run by default). A live push needs a token and node.

    ``overwrite=True`` adds a new version of an existing file of the same name.
    """
    kw = {} if base_url is None else {"base_url": base_url}
    return _osf_push(self.data, token=token, node=node, filename=filename, dry_run=dry_run,
                     overwrite=overwrite, **kw)

post_data_severity()

How severely each bounded prediction passed its test, from the outcome's estimate and SE.

See :func:theoryforge.post_data_severity for the rule and its refusals.

Source code in src/theoryforge/core.py
742
743
744
745
746
747
def post_data_severity(self) -> list[dict]:
    """How severely each bounded prediction passed its test, from the outcome's estimate and SE.

    See :func:`theoryforge.post_data_severity` for the rule and its refusals.
    """
    return _post_data_severity(self.data)

preregister(path=None)

Render the hypotheses section of a preregistration, writing it to path when one is given (see :func:theoryforge.preregister).

Source code in src/theoryforge/core.py
768
769
770
771
def preregister(self, path=None) -> str:
    """Render the hypotheses section of a preregistration, writing it to ``path``
    when one is given (see :func:`theoryforge.preregister`)."""
    return _preregister(self.data, path)

render_diagram(type='nomological_net')

Render a digraph view via the optional graphviz library; see :func:theoryforge.render.render_diagram.

Source code in src/theoryforge/core.py
732
733
734
735
736
def render_diagram(self, type: str = "nomological_net"):
    """Render a digraph view via the optional ``graphviz`` library; see
    :func:`theoryforge.render.render_diagram`."""
    from .render import render_diagram as _render_diagram
    return _render_diagram(self.data, type=type)

render_report(path, title=None, render=False, to='html')

Write (and optionally render) a Quarto report of the audit dossier.

Source code in src/theoryforge/core.py
809
810
811
def render_report(self, path, title=None, render: bool = False, to: str = "html") -> str:
    """Write (and optionally render) a Quarto report of the audit dossier."""
    return _render_report(self.data, path, title=title, render=render, to=to)

severity()

Per-prediction risk and computed severity from the claim-form riskiness rubric.

Source code in src/theoryforge/core.py
738
739
740
def severity(self) -> list[dict]:
    """Per-prediction risk and computed severity from the claim-form riskiness rubric."""
    return _severity(self.data)

simulate(steps=10, dt=0.1, k=1.0, damping=0.5, init=1.0, method='euler')

Propagate the construct network as a linear dynamical system (see theoryforge.simulate).

Source code in src/theoryforge/core.py
799
800
801
802
803
def simulate(self, steps: int = 10, dt: float = 0.1, k: float = 1.0,
             damping: float = 0.5, init: float = 1.0, method: str = "euler") -> dict:
    """Propagate the construct network as a linear dynamical system (see ``theoryforge.simulate``)."""
    return _simulate(self.data, steps=steps, dt=dt, k=k, damping=damping, init=init,
                     method=method)

validate(*, full=False)

Check the theory against the schema, theory.schema.json.

The default pass checks structure. It covers the required top-level fields, the maturity and theory_form enums, the top-level field names and the required fields and enums of each construct, proposition and prediction. It also checks that every collection is a list. A field that is absent, null or blank is reported as missing, and one that holds another type as such (id must be a string).

With full=True it also checks that every id is unique within its collection and that every cross-reference (proposition endpoints, prediction derivations and diagnostics, assumption, evidence and test-outcome targets) points to a declared id. It then checks the rest of the schema. That covers the required fields of assumptions, alternatives, evidence and test outcomes, a boolean passed, the evidence direction and formal-model type enums, the version block and the schema_version pattern. It also covers numbers within [0, 1] where the schema asks for them, a prediction's bounds and test block, a test outcome's estimate and se, and the type of every other field and of every entry of a string array.

CI compares the result with a JSON Schema 2020-12 validator. Besides the ids and references, which the schema cannot express, the pass is stricter in two ways, since a blank string counts as missing and an entry of a string array must be a nonempty string. It is more lenient in three ways, which API_SPEC.md section 2 documents. A null optional field is absent and a single string stands for a one-element array of strings. An empty sequence and an empty mapping stand for each other.

Returns:

Type Description
bool

True when the theory passes.

Raises:

Type Description
ValueError

"invalid theory object: " followed by every problem found, joined by "; ", in the order API_SPEC.md section 2 fixes. The R twin's tf_validate() gives the same message.

Source code in src/theoryforge/core.py
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
def validate(self, *, full: bool = False) -> bool:
    """Check the theory against the schema, ``theory.schema.json``.

    The default pass checks structure. It covers the required top-level
    fields, the ``maturity`` and ``theory_form`` enums, the top-level field
    names and the required fields and enums of each construct, proposition
    and prediction. It also checks that every collection is a list. A field
    that is absent, null or blank is reported as missing, and one that holds
    another type as such (``id must be a string``).

    With ``full=True`` it also checks that every id is unique within its
    collection and that every cross-reference (proposition endpoints,
    prediction derivations and diagnostics, assumption, evidence and
    test-outcome targets) points to a declared id. It then checks the rest
    of the schema. That covers the required fields of assumptions,
    alternatives, evidence and test outcomes, a boolean ``passed``, the
    evidence direction and formal-model type enums, the version block and
    the ``schema_version`` pattern. It also covers numbers within [0, 1]
    where the schema asks for them, a prediction's ``bounds`` and ``test``
    block, a test outcome's ``estimate`` and ``se``, and the type of every
    other field and of every entry of a string array.

    CI compares the result with a JSON Schema 2020-12 validator. Besides the
    ids and references, which the schema cannot express, the pass is
    stricter in two ways, since a blank string counts as missing and an
    entry of a string array must be a nonempty string. It is more lenient
    in three ways, which API_SPEC.md section 2 documents. A null optional
    field is absent and a single string stands for a one-element array of
    strings. An empty sequence and an empty mapping stand for each other.

    Returns:
        True when the theory passes.

    Raises:
        ValueError: ``"invalid theory object: "`` followed by every problem
            found, joined by ``"; "``, in the order API_SPEC.md section 2
            fixes. The R twin's ``tf_validate()`` gives the same message.
    """
    errors: list[str] = []
    d = self.data
    for req in ("schema_version", "id", "title", "maturity"):
        errors += _required_text(d.get(req), f"missing/empty required field: {req}", req)
    # Each enum test asks whether the value is a nonempty string before
    # asking whether it is a member. A YAML list or mapping reaches these
    # lines whenever a field is mistyped, and `x in <set>` raises TypeError
    # on an unhashable value, which would abandon the collected errors and
    # report something the contract never promised. The string test also
    # keeps the twins level: R's `%in%` coerces a one-element list to its
    # element, so `theory_form: [network]` used to validate there and be
    # refused here.
    mat = d.get("maturity")
    if not (_nonempty_str(mat) and mat in _MATURITY):
        errors.append(f"maturity must be one of {', '.join(sorted(_MATURITY))}")
    if "theory_form" in d:
        form = d["theory_form"]
        if not (_nonempty_str(form) and form in _FORM):
            errors.append(f"theory_form must be one of {', '.join(sorted(_FORM))}")
    # A misspelt collection key would otherwise pass silently and quietly
    # change every downstream verdict (a renamed `predictions:` drops the
    # whole collection), so unrecognised top-level fields are refused.
    known = _resources.theory_schema().get("properties", {})
    for key in d:
        if key not in known:
            errors.append(f"unknown top-level field: {key}")
    # A collection that is not a list reads as empty (API_SPEC.md section 3),
    # so the theory would lose it without a word.
    for key in _COLLECTIONS:
        if _not_a_list(d.get(key)):
            errors.append(f"{key} must be a list")
    for i, c in enumerate(self._list("constructs")):
        errors += _required_fields(c, f"construct[{i}]", ("id", "label", "definition"))
    for i, p in enumerate(self._list("propositions")):
        errors += _required_fields(p, f"proposition[{i}]", ("id", "from", "to", "relation"))
        rel = _field(p, "relation")
        if _nonempty_str(rel) and rel not in _RELATION:
            errors.append(f"proposition[{i}] relation '{rel}' not allowed")
    for i, p in enumerate(self._list("predictions")):
        errors += _required_fields(p, f"prediction[{i}]", ("id", "statement", "type"))
        ty = _field(p, "type")
        if _nonempty_str(ty) and ty not in _PRED_TYPE:
            errors.append(f"prediction[{i}] type '{ty}' not allowed")

    if full:
        self._full_errors(errors)

    if errors:
        raise ValueError("invalid theory object: " + "; ".join(errors))
    return True

write(path)

Write the theory to path as JSON (a .json suffix) or YAML (anything else).

The file is UTF-8 with LF line endings and ends with a newline. YAML keeps the theory's key order and quotes every string a reader could take for something else, y, Y, n and N included. The file therefore reads back as the same theory in both twins, and in theoryforge 0.6.0 for R (API_SPEC.md section 3, "Reading and writing files").

Source code in src/theoryforge/core.py
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
def write(self, path) -> None:
    """Write the theory to ``path`` as JSON (a ``.json`` suffix) or YAML (anything else).

    The file is UTF-8 with LF line endings and ends with a newline. YAML keeps
    the theory's key order and quotes every string a reader could take for
    something else, ``y``, ``Y``, ``n`` and ``N`` included. The file therefore
    reads back as the same theory in both twins, and in theoryforge 0.6.0 for R
    (API_SPEC.md section 3, "Reading and writing files").
    """
    path = Path(path)
    if path.suffix.lower() == ".json":
        text = json.dumps(self.data, indent=2, ensure_ascii=False) + "\n"
    else:
        text = _dump_yaml(self.data)
    _write_lf(path, text)

theoryforge.new_theory(id, title, maturity='building', theory_form='network')

Start a new, empty theory object (BUILDING mode entry point).

Source code in src/theoryforge/core.py
849
850
851
852
853
854
855
856
857
858
859
def new_theory(id: str, title: str, maturity: str = "building", theory_form: str = "network") -> Theory:
    """Start a new, empty theory object (BUILDING mode entry point)."""
    t = Theory({
        "schema_version": "1.0",
        "id": id,
        "title": title,
        "maturity": maturity,
        "theory_form": theory_form,
    })
    t._provenance("tf_theory", id)
    return t

theoryforge.read(path)

Read a theory object from a YAML or JSON file (JSON when the suffix is .json).

The file is read exactly as the R twin's tf_read() reads it (API_SPEC.md section 3, "Reading and writing files"). A byte-order mark is ignored. Dates and y, Y, n and N stay strings, and integers are decimal, octal or hexadecimal only, so 1:30 and 1_000 are strings too. A merge key lets the mapping's own keys win. A repeated key anywhere raises ValueError("(<path>) Duplicate map key: '<key>'"), and a document that is not a mapping raises ValueError("Theory data must be a mapping").

Source code in src/theoryforge/core.py
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
def read(path) -> Theory:
    """Read a theory object from a YAML or JSON file (JSON when the suffix is ``.json``).

    The file is read exactly as the R twin's ``tf_read()`` reads it (API_SPEC.md
    section 3, "Reading and writing files"). A byte-order mark is ignored. Dates
    and ``y``, ``Y``, ``n`` and ``N`` stay strings, and integers are decimal, octal
    or hexadecimal only, so ``1:30`` and ``1_000`` are strings too. A merge key
    lets the mapping's own keys win. A repeated key anywhere raises
    ``ValueError("(<path>) Duplicate map key: '<key>'")``, and a document that is
    not a mapping raises ``ValueError("Theory data must be a mapping")``.
    """
    data = _load_document(path)
    if not isinstance(data, dict):
        raise ValueError("Theory data must be a mapping")
    return Theory(data)

theoryforge.write(theory, path)

Write a theory object to YAML or JSON (chosen by file extension).

Source code in src/theoryforge/core.py
844
845
846
def write(theory: Theory, path) -> None:
    """Write a theory object to YAML or JSON (chosen by file extension)."""
    theory.write(path)

Packaged examples

Example theories shipped inside the package, so that nothing has to be downloaded to follow the documentation. The R twin reaches the same files through tf_example_names() and tf_example_path().

theoryforge.example_names()

The names of the example theory and corpus files, sorted.

Only .yaml entries count, matching the R twin's list.files(pattern = "\\.yaml$"). Listing the directory wholesale would let anything else that lands beside the examples, such as a __pycache__ directory left by a local build, show up here and not in R.

Source code in src/theoryforge/examples.py
14
15
16
17
18
19
20
21
22
23
def example_names() -> list[str]:
    """The names of the example theory and corpus files, sorted.

    Only ``.yaml`` entries count, matching the R twin's ``list.files(pattern =
    "\\\\.yaml$")``. Listing the directory wholesale would let anything else
    that lands beside the examples, such as a ``__pycache__`` directory left by
    a local build, show up here and not in R.
    """
    entries = (files("theoryforge") / "fixtures").iterdir()
    return sorted(p.name for p in entries if p.name.endswith(".yaml"))

theoryforge.example_path(name)

Filesystem path of a packaged example, e.g. panic-network.theory.yaml.

Raises FileNotFoundError naming the available files when name is not one of them.

Source code in src/theoryforge/examples.py
26
27
28
29
30
31
32
33
34
35
36
37
38
def example_path(name: str) -> Path:
    """Filesystem path of a packaged example, e.g. ``panic-network.theory.yaml``.

    Raises ``FileNotFoundError`` naming the available files when ``name`` is not
    one of them.
    """
    resource = files("theoryforge") / "fixtures" / name
    if not resource.is_file():
        raise FileNotFoundError(f"no packaged example named {name!r}; available: {example_names()}")
    # A wheel is a directory on disk once installed, so this resolves to a real
    # path; as_file() keeps the call correct for a zipped install too.
    with as_file(resource) as path:
        return Path(path)

Rigour

Score a theory against the versioned rigour checklist, and report what the score was made of and how severely each bounded prediction passed its test.

theoryforge.check(T)

Compute the full rigour report (dict) for a Theory or theory mapping.

Each item has a status (pass, warn or fail) and a score in [0, 1]. An item with nothing to assess, such as the redundancy screen of a theory with one construct, has the status n/a and the score None, and is left out of the aggregate. The aggregate is the weighted mean of the applicable items' scores, times 100, and coverage is the share of the checklist's weight that was applicable (API_SPEC.md section 4).

Raises:

Type Description
ValueError

a test outcome's passed is present and not a boolean, or a prediction's severity is not a finite number or lies outside [0, 1] (API_SPEC.md section 4, item 3). validate(full=True) reports both.

Source code in src/theoryforge/rigor.py
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
def check(T) -> dict:
    """Compute the full rigour report (dict) for a Theory or theory mapping.

    Each item has a status (``pass``, ``warn`` or ``fail``) and a score in
    [0, 1]. An item with nothing to assess, such as the redundancy screen of a
    theory with one construct, has the status ``n/a`` and the score None, and
    is left out of the aggregate. The aggregate is the weighted mean of the
    applicable items' scores, times 100, and ``coverage`` is the share of the
    checklist's weight that was applicable (API_SPEC.md section 4).

    Raises:
        ValueError: a test outcome's ``passed`` is present and not a boolean,
            or a prediction's ``severity`` is not a finite number or lies
            outside [0, 1] (API_SPEC.md section 4, item 3).
            ``validate(full=True)`` reports both.
    """
    T = T.data if hasattr(T, "data") else T
    _refuse_non_boolean_outcomes(T, "check")
    spec = _resources.checklist()
    thr = spec["thresholds"]
    results = _check_items(T, thr)

    items = []
    weighted = 0.0
    applicable = 0.0
    n_blockers_failed = 0
    for spec_item in spec["items"]:
        iid = spec_item["id"]
        status, score = results[iid]
        # An item with nothing to assess is in neither sum. It used to score
        # 1.0, so the empty theory scored 26 and outscored a weak but real one.
        if score is not None:
            weighted += spec_item["weight"] * score
            applicable += spec_item["weight"]
        if spec_item["severity_if_fail"] == "blocker" and status == "fail":
            n_blockers_failed += 1
        items.append({
            "id": iid,
            "status": status,
            "score": score,
            "weight": spec_item["weight"],
            "severity_if_fail": spec_item["severity_if_fail"],
            "citation": spec_item["citation"],
        })

    # Nulls, numbers and sequences in these fields read as "" (API_SPEC.md
    # section 3, "Reading a theory"), and a maturity outside its enum is
    # absent. The report is compared across the twins, so the value printed
    # here must be one both engines can produce.
    maturity = enum(T.get("maturity"), MATURITY) or ""
    if maturity == "draft":
        gate = "advisory"
    else:
        gate = "blocked" if n_blockers_failed > 0 else "pass"

    return {
        "theory_id": text(T.get("id")),
        "schema_version": text(T.get("schema_version")),
        # Every number below comes from the checklist's weights and thresholds,
        # so two reports are only comparable if they were scored against the
        # same checklist. `schema_version` above is the theory's, not this.
        "checklist_version": spec.get("schema_version") or "",
        "maturity": maturity,
        # Nine of the twelve items always apply, so `applicable` is at least 0.74.
        "aggregate_score": rnd(weighted / applicable * 100, 1),
        "coverage": rnd(applicable, 3),
        "gate": gate,
        "n_blockers_failed": n_blockers_failed,
        "items": items,
    }

theoryforge.report(T, format='json')

Render the rigour report. format in {'json', 'html'}.

The HTML fragment escapes every value it interpolates.

Source code in src/theoryforge/rigor.py
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
def report(T, format: str = "json") -> str:
    """Render the rigour report. format in {'json', 'html'}.

    The HTML fragment escapes every value it interpolates.
    """
    rep = check(T)  # check() unwraps Theory -> mapping
    if format == "json":
        return json.dumps(rep, indent=2, ensure_ascii=False)
    if format == "html":
        # Every interpolated value is escaped: the schema allows any non-empty id,
        # so '<' or '&' in it would otherwise reach the markup.
        rows = "\n".join(
            f'    <tr><td>{_xml(it["id"])}</td><td>{_xml(it["status"])}</td>'
            f'<td>{"n/a" if it["score"] is None else _xml(it["score"])}</td>'
            f'<td>{_xml(it["citation"])}</td></tr>'
            for it in rep["items"]
        )
        return (
            f'<section class="theoryforge-report">\n'
            f'  <h2>Rigour report: {_xml(rep["theory_id"])}</h2>\n'
            f'  <p>Aggregate score: <strong>{_xml(rep["aggregate_score"])}</strong> &middot; '
            f'gate: <strong>{_xml(rep["gate"])}</strong></p>\n'
            f'  <table>\n    <tr><th>item</th><th>status</th><th>score</th><th>grounding</th></tr>\n'
            f'{rows}\n  </table>\n</section>\n'
        )
    raise ValueError(f"unknown report format: {format!r}")

theoryforge.severity(T)

Per-prediction claim-form riskiness, in file order.

A pre-data rubric of the form of each prediction's claim. risk_score is the base riskiness of the declared type: existence 0.1, directional 0.4, interval 0.7 and point 0.9. computed_severity discounts a directional prediction by 25 per cent, adds 0.1 when diagnostic_vs names a registered alternative and is capped at 1. The order of the types follows Popper (1959, sections 31-33) for nested claims about one quantity, and the directional penalty follows Meehl's (1967, 1990b) argument that a sign alone risks little. The base values, the discount and the bonus are the package's conventions, and neither source gives them.

How severely a claim is tested depends on the design and the data, which the rubric does not read. A prediction scores the same however it is tested, and a failed test leaves the value where it was.

References

Meehl, P. E. (1967). Theory-testing in psychology and physics: A methodological paradox. Philosophy of Science, 34(2), 103-115. https://doi.org/10.1086/288135 Meehl, P. E. (1990b). Why summaries of research on psychological theories are often uninterpretable. Psychological Reports, 66(1), 195-244. https://doi.org/10.2466/pr0.1990.66.1.195 Popper, K. R. (1959). The logic of scientific discovery. Hutchinson.

Source code in src/theoryforge/scoring.py
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
def severity(T) -> list[dict]:
    """Per-prediction claim-form riskiness, in file order.

    A pre-data rubric of the form of each prediction's claim. ``risk_score`` is
    the base riskiness of the declared type: existence 0.1, directional 0.4,
    interval 0.7 and point 0.9. ``computed_severity`` discounts a directional
    prediction by 25 per cent, adds 0.1 when ``diagnostic_vs`` names a registered
    alternative and is capped at 1. The order of the types follows Popper (1959,
    sections 31-33) for nested claims about one quantity, and the directional
    penalty follows Meehl's (1967, 1990b) argument that a sign alone risks little.
    The base values, the discount and the bonus are the package's conventions,
    and neither source gives them.

    How severely a claim is tested depends on the design and the data, which the
    rubric does not read. A prediction scores the same however it is tested, and
    a failed test leaves the value where it was.

    References:
        Meehl, P. E. (1967). Theory-testing in psychology and physics: A
        methodological paradox. Philosophy of Science, 34(2), 103-115.
        https://doi.org/10.1086/288135
        Meehl, P. E. (1990b). Why summaries of research on psychological theories
        are often uninterpretable. Psychological Reports, 66(1), 195-244.
        https://doi.org/10.2466/pr0.1990.66.1.195
        Popper, K. R. (1959). The logic of scientific discovery. Hutchinson.
    """
    T = T.data if hasattr(T, "data") else T
    preds = items(T, "predictions")
    alt_ids = {text(field(a, "id")) for a in items(T, "alternatives")}
    out = []
    for p in preds:
        typ = enum(field(p, "type"), PRED_TYPE) or ""
        base = BASE.get(typ, 0.0)
        discounted = base * (1 - CRUD) if typ == "directional" else base
        dv = str_list(field(p, "diagnostic_vs"))
        # A package convention: naming a registered alternative the prediction
        # would discriminate from adds 0.1. The alternative is declared, not
        # checked against the statement.
        diag_bonus = 0.1 if any(d in alt_ids for d in dv) else 0.0
        out.append({
            "prediction_id": text(field(p, "id")),
            "type": typ,
            "risk_score": rnd(base, 3),
            "computed_severity": rnd(min(1.0, discounted + diag_bonus), 3),
        })
    return out

theoryforge.post_data_severity(T)

How severely each bounded claim passed its test, from the outcome's estimate and SE.

A prediction states the region its claim allows in bounds, a mapping of lower and upper, either of which may be null (or infinite) for an open side. A test outcome records the estimate it observed and its standard error se. For an outcome with both, whose prediction declares bounds, the severity with which the claim passed is the smallest of Phi((estimate - lower) / se) and Phi((upper - estimate) / se) over the finite bounds, rounded to three decimals (Mayo & Spanos, 2006, 2011). It is the probability that the test would have given a result less in accord with the claim, had the claim been false at the bound. A one-sided claim, such as an effect above a smallest effect of interest, is a lower bound with the upper one open.

The computation is a normal approximation to the sampling distribution of the estimate. With two finite bounds, the value equals 1 minus the p value of the two one-sided tests (TOST) of equivalence. A point claim with lower equal to upper can pass with a severity of at most 0.5, so it needs a tolerance around its value. Unlike severity(), which grades the form of a claim before any data, this value depends on the design and the data. The rigour checklist does not read it.

Returns:

Type Description
list[dict]

One record per test outcome, in file order, that carries both an

list[dict]

estimate and a standard error and whose prediction declares bounds:

list[dict]

{prediction_id, outcome, lower, upper, estimate, se, severity}.

list[dict]

outcome is the outcome's 0-based index among the test outcomes, and

list[dict]

an open bound is None.

Raises:

Type Description
ValueError

for the first outcome, in file order, that cannot be read, with the R twin's text: post_data_severity requires both estimate and se; test_outcome[<i>], ... requires a finite se greater than 0; ..., ... requires a finite estimate; ..., and for its prediction's bounds ... requires bounds with numeric or null lower and upper; prediction '<id>', ... requires at least one finite bound; ... and ... requires lower <= upper; .... validate(full=True) reports each of these.

References

Mayo, D. G., & Spanos, A. (2006). Severe testing as a basic concept in a Neyman-Pearson philosophy of induction. The British Journal for the Philosophy of Science, 57(2), 323-357. https://doi.org/10.1093/bjps/axl003 Mayo, D. G., & Spanos, A. (2011). Error statistics. In P. S. Bandyopadhyay & M. R. Forster (Eds.), Philosophy of statistics (pp. 153-198). Elsevier. https://doi.org/10.1016/B978-0-444-51862-0.50005-8

Source code in src/theoryforge/scoring.py
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
def post_data_severity(T) -> list[dict]:
    """How severely each bounded claim passed its test, from the outcome's estimate and SE.

    A prediction states the region its claim allows in ``bounds``, a mapping of
    ``lower`` and ``upper``, either of which may be null (or infinite) for an
    open side. A test outcome records the ``estimate`` it observed and its
    standard error ``se``. For an outcome with both, whose prediction declares
    bounds, the severity with which the claim passed is the smallest of
    ``Phi((estimate - lower) / se)`` and ``Phi((upper - estimate) / se)`` over
    the finite bounds, rounded to three decimals (Mayo & Spanos, 2006, 2011).
    It is the probability that the test would have given a result less in
    accord with the claim, had the claim been false at the bound. A one-sided
    claim, such as an effect above a smallest effect of interest, is a lower
    bound with the upper one open.

    The computation is a normal approximation to the sampling distribution of
    the estimate. With two finite bounds, the value equals 1 minus the p value of
    the two one-sided tests (TOST) of equivalence. A point claim with lower
    equal to upper can pass with a severity of at most 0.5, so it needs a
    tolerance around its value. Unlike ``severity()``, which grades the form of
    a claim before any data, this value depends on the design and the data. The
    rigour checklist does not read it.

    Returns:
        One record per test outcome, in file order, that carries both an
        estimate and a standard error and whose prediction declares bounds:
        ``{prediction_id, outcome, lower, upper, estimate, se, severity}``.
        ``outcome`` is the outcome's 0-based index among the test outcomes, and
        an open bound is None.

    Raises:
        ValueError: for the first outcome, in file order, that cannot be read,
            with the R twin's text: ``post_data_severity requires both estimate
            and se; test_outcome[<i>]``, ``... requires a finite se greater than
            0; ...``, ``... requires a finite estimate; ...``, and for its
            prediction's bounds ``... requires bounds with numeric or null lower
            and upper; prediction '<id>'``, ``... requires at least one finite
            bound; ...`` and ``... requires lower <= upper; ...``.
            ``validate(full=True)`` reports each of these.

    References:
        Mayo, D. G., & Spanos, A. (2006). Severe testing as a basic concept in a
        Neyman-Pearson philosophy of induction. The British Journal for the
        Philosophy of Science, 57(2), 323-357. https://doi.org/10.1093/bjps/axl003
        Mayo, D. G., & Spanos, A. (2011). Error statistics. In P. S.
        Bandyopadhyay & M. R. Forster (Eds.), Philosophy of statistics (pp.
        153-198). Elsevier. https://doi.org/10.1016/B978-0-444-51862-0.50005-8
    """
    rows = []
    for row, refusal in outcome_rows(T):
        if refusal is not None:
            raise ValueError(refusal)
        if row is not None:
            rows.append(row)
    return rows

Redundancy

Screen the definitions of a theory's constructs for lexical redundancy, deterministically, with an opt-in embedding variant.

theoryforge.tokens(s)

Tokenise a string into a set of content tokens (API_SPEC.md section 6).

The text is folded through schema/fold.json (accented Latin letters to ASCII, Greek and Cyrillic to small unaccented letters) and lowercased in ASCII. A token is a maximal run of letters, marks and digits in any script (Unicode general categories L, M and N). Tokens shorter than three code points and English stopwords are dropped.

Source code in src/theoryforge/redundancy.py
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
def tokens(s: str) -> set[str]:
    """Tokenise a string into a set of content tokens (API_SPEC.md section 6).

    The text is folded through ``schema/fold.json`` (accented Latin letters to
    ASCII, Greek and Cyrillic to small unaccented letters) and lowercased in
    ASCII. A token is a maximal run of letters, marks and digits in any script
    (Unicode general categories L, M and N). Tokens shorter than three code
    points and English stopwords are dropped.
    """
    s = normalise_words(s or "")
    parts: list[str] = []
    run: list[str] = []
    for ch in s:
        if unicodedata.category(ch)[0] in "LMN":
            run.append(ch)
        elif run:
            parts.append("".join(run))
            run = []
    if run:
        parts.append("".join(run))
    return {t for t in parts if len(t) >= 3 and t not in STOPWORDS}

theoryforge.jaccard(a, b)

Source code in src/theoryforge/redundancy.py
44
45
46
47
48
49
def jaccard(a: set[str], b: set[str]) -> float:
    if not a and not b:
        return 0.0
    inter = len(a & b)
    union = len(a | b)
    return rnd(inter / union, 3)

theoryforge.redundancy_check(T)

Pairwise lexical similarity of construct definitions.

Returns one record per unordered construct pair, {a, b, similarity, overlap, flag}, sorted by descending similarity then (a, b) ascending. similarity is the Jaccard index of the two definitions' token sets and overlap their overlap coefficient, the shared tokens over the tokens of the shorter definition. flag is review for a near-duplicate, a Jaccard similarity at or above the checklist's redundancy_similarity_max, and for a definition contained in the other, an overlap at or above redundancy_overlap_max when both definitions hold at least three tokens. It is ok otherwise.

The screen compares words, so it cannot detect empirical redundancy, two differently defined constructs that correlate almost perfectly once measurement error is corrected for (Le et al., 2010). Sibling constructs (Lawson & Robins, 2021) may share vocabulary without being redundant.

References

Le, H., Schmidt, F. L., Harter, J. K., & Lauver, K. J. (2010). The problem of empirical redundancy of constructs in organizational research: An empirical investigation. Organizational Behavior and Human Decision Processes, 112(2), 112-125. https://doi.org/10.1016/j.obhdp.2010.02.003 Lawson, K. M., & Robins, R. W. (2021). Sibling constructs: What are they, why do they matter, and how should you handle them? Personality and Social Psychology Review, 25(4), 344-366. https://doi.org/10.1177/10888683211047101

Source code in src/theoryforge/redundancy.py
 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
def redundancy_check(T: dict) -> list[dict]:
    """Pairwise lexical similarity of construct definitions.

    Returns one record per unordered construct pair, ``{a, b, similarity,
    overlap, flag}``, sorted by descending similarity then ``(a, b)``
    ascending. ``similarity`` is the Jaccard index of the two definitions'
    token sets and ``overlap`` their overlap coefficient, the shared tokens
    over the tokens of the shorter definition. ``flag`` is ``review`` for a
    near-duplicate, a Jaccard similarity at or above the checklist's
    ``redundancy_similarity_max``, and for a definition contained in the other,
    an overlap at or above ``redundancy_overlap_max`` when both definitions
    hold at least three tokens. It is ``ok`` otherwise.

    The screen compares words, so it cannot detect empirical redundancy, two
    differently defined constructs that correlate almost perfectly once
    measurement error is corrected for (Le et al., 2010). Sibling constructs
    (Lawson & Robins, 2021) may share vocabulary without being redundant.

    References:
        Le, H., Schmidt, F. L., Harter, J. K., & Lauver, K. J. (2010). The
        problem of empirical redundancy of constructs in organizational
        research: An empirical investigation. Organizational Behavior and Human
        Decision Processes, 112(2), 112-125.
        https://doi.org/10.1016/j.obhdp.2010.02.003
        Lawson, K. M., & Robins, R. W. (2021). Sibling constructs: What are
        they, why do they matter, and how should you handle them? Personality
        and Social Psychology Review, 25(4), 344-366.
        https://doi.org/10.1177/10888683211047101
    """
    T = T.data if hasattr(T, "data") else T
    rows = _pairs(T, _resources.checklist()["thresholds"])
    rows.sort(key=lambda r: (-r["similarity"], r["a"], r["b"]))
    return rows

theoryforge.embedding_redundancy(T, embedder, threshold=None)

Pairwise cosine similarity of embedded construct definitions.

embedder maps a definition string to a numeric vector; the vectors of every compared pair must be of equal, nonzero length, or the pair is refused. Returns one record per unordered construct pair, sorted by descending similarity then (a, b), with a review/ok flag.

threshold defaults to the checklist's embedding_similarity_max (0.85). How high a cosine two unrelated definitions reach depends on the embedding model, so a threshold suited to one model may flag everything or nothing under another, and the value is best set for the model in use.

Source code in src/theoryforge/embedding.py
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
def embedding_redundancy(T, embedder: Callable[[str], Sequence[float]],
                         threshold: float | None = None) -> list[dict]:
    """Pairwise cosine similarity of embedded construct definitions.

    `embedder` maps a definition string to a numeric vector; the vectors of every compared
    pair must be of equal, nonzero length, or the pair is refused. Returns one record per
    unordered construct pair, sorted by descending similarity then (a, b), with a
    `review`/`ok` flag.

    `threshold` defaults to the checklist's `embedding_similarity_max` (0.85). How high
    a cosine two unrelated definitions reach depends on the embedding model, so a
    threshold suited to one model may flag everything or nothing under another, and
    the value is best set for the model in use.
    """
    T = T.data if hasattr(T, "data") else T
    if threshold is None:
        threshold = _resources.checklist()["thresholds"]["embedding_similarity_max"]
    cons = items(T, "constructs")
    vecs = [(text(field(c, "id")), embedder(text(field(c, "definition")))) for c in cons]
    rows: list[dict] = []
    for i in range(len(vecs)):
        for j in range(i + 1, len(vecs)):
            # Unequal or empty vectors have no defensible cosine, and the two
            # engines read them differently by accident (R recycled the
            # shorter vector, Python truncated the longer), so the same
            # embedder produced two confident, different similarities. Refuse
            # instead of picking a reading.
            la, lb = len(vecs[i][1]), len(vecs[j][1])
            if la == 0 or lb == 0 or la != lb:
                raise ValueError(
                    "embedding_redundancy requires equal-length nonempty embedding vectors; "
                    f"constructs {vecs[i][0] or ''} and {vecs[j][0] or ''} "
                    f"have lengths {la} and {lb}"
                )
            sim = rnd(_cosine(vecs[i][1], vecs[j][1]), 6)
            rows.append({
                "a": vecs[i][0], "b": vecs[j][0],
                "cosine": sim, "flag": "review" if sim >= threshold else "ok",
            })
    rows.sort(key=lambda r: (-r["cosine"], r["a"], r["b"]))
    return rows

Diagram

Byte-identical diagram intermediate representations, and native rendering of them.

theoryforge.diagram.diagram(T, type='nomological_net', engine='graphviz')

Return the diagram IR string for the requested type.

engine is accepted but has no effect, because the IR is engine-independent (DOT for the digraphs, dagitty syntax for the causal DAG, and SVG for the Venn, the rigour grid and the severity chart).

The causal DAG is read by dagitty and drawn by the apps with Graphviz. It writes a construct id bare only when the id is made of ASCII letters, digits and underscores, does not begin with a digit and is not a DOT keyword. The keywords are node, edge, graph, digraph, subgraph and strict, in any case. Any other id is double-quoted, with a double quote inside it escaped by a backslash, so self-efficacy stays one node where dagitty used to read two.

The pipeline and workflow views give every test outcome a node of its own. The first outcome of a prediction is result_<id> (outcome_<id> in the workflow) and each later one result_<id>#<k>, where <k> counts the prediction's outcomes in file order, so a failed replication is not merged into an earlier pass. When a prediction has several outcomes, each label also carries the outcome's date.

Raises:

Type Description
ValueError

for an unknown type, and for a causal DAG that would write the id node or graph, which dagitty refuses even quoted.

Source code in src/theoryforge/diagram.py
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
def diagram(T: dict, type: str = "nomological_net", engine: str = "graphviz") -> str:
    """Return the diagram IR string for the requested type.

    ``engine`` is accepted but has no effect, because the IR is engine-independent
    (DOT for the digraphs, dagitty syntax for the causal DAG, and SVG for the Venn,
    the rigour grid and the severity chart).

    The causal DAG is read by dagitty and drawn by the apps with Graphviz. It
    writes a construct id bare only when the id is made of ASCII letters,
    digits and underscores, does not begin with a digit and is not a DOT
    keyword. The keywords are ``node``, ``edge``, ``graph``, ``digraph``,
    ``subgraph`` and ``strict``, in any case. Any other id is double-quoted,
    with a double quote inside it escaped by a backslash, so ``self-efficacy``
    stays one node where dagitty used to read two.

    The pipeline and workflow views give every test outcome a node of its own.
    The first outcome of a prediction is ``result_<id>`` (``outcome_<id>`` in
    the workflow) and each later one ``result_<id>#<k>``, where ``<k>`` counts
    the prediction's outcomes in file order, so a failed replication is not
    merged into an earlier pass. When a prediction has several outcomes, each
    label also carries the outcome's date.

    Raises:
        ValueError: for an unknown ``type``, and for a causal DAG that would
            write the id ``node`` or ``graph``, which dagitty refuses even
            quoted.
    """
    T = T.data if hasattr(T, "data") else T
    if type == "nomological_net":
        return _nomological_net(T)
    if type == "provenance":
        return _provenance(T)
    if type == "causal_dag":
        return _causal_dag(T)
    if type == "development_roadmap":
        return _development_roadmap(T)
    if type == "pipeline":
        return _pipeline(T)
    if type == "context":
        return _context(T)
    if type == "workflow":
        return _workflow(T)
    if type == "venn":
        return _venn(T)
    if type == "rigour":
        return _rigor(T)
    if type == "severity":
        return _severity_chart(T)
    raise ValueError(f"unknown diagram type {type!r}; expected one of {_TYPES}")

theoryforge.lit_diagram(obj, type='keyword_cooccurrence', max_edges=None)

DOT for the literature layer. type in {keyword_cooccurrence, co_citation, theme_landscape}.

max_edges caps a keyword_cooccurrence or co_citation diagram at that many edges, the highest counts first and ties broken by (a, b); only their endpoints are drawn. None draws every edge, and theme_landscape ignores it.

Source code in src/theoryforge/lit.py
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
def lit_diagram(obj: dict, type: str = "keyword_cooccurrence", max_edges: int | None = None) -> str:
    """DOT for the literature layer. type in {keyword_cooccurrence, co_citation, theme_landscape}.

    ``max_edges`` caps a keyword_cooccurrence or co_citation diagram at that
    many edges, the highest counts first and ties broken by (a, b); only their
    endpoints are drawn. None draws every edge, and theme_landscape ignores it.
    """
    if max_edges is not None:
        max_edges = _positive_int(max_edges, "max_edges")
    if type in ("keyword_cooccurrence", "co_citation"):
        edges = obj.get(type, [])
        if max_edges is not None and len(edges) > max_edges:
            edges = _strongest(edges, max_edges)
        return _undirected(type, edges)
    if type == "theme_landscape":
        return _theme_landscape(obj)
    raise ValueError(
        f"unknown lit diagram type {type!r}; expected one of "
        "('keyword_cooccurrence', 'co_citation', 'theme_landscape')"
    )

theoryforge.render_diagram(x, type='nomological_net')

Render a digraph view without leaving Python.

Where :func:theoryforge.diagram.diagram returns the deterministic Graphviz DOT string, render_diagram wraps it in a graphviz.Source, which displays inline in Jupyter and renders to a file with its render method. Requires the optional graphviz <https://pypi.org/project/graphviz/>_ library (pip install "theoryforge[render]") and, to write image files, the Graphviz system binaries.

The three chart views (venn, rigour and severity) are already SVG, so they come back as an :class:SVGString, which also displays inline in Jupyter. The causal_dag view emits dagitty syntax rather than DOT, so it is not rendered here; paste it into a dagitty tool instead.

Parameters

x: A :class:~theoryforge.core.Theory, a parsed theory mapping, or a diagram IR string from diagram() or lit_diagram(), so literature diagrams render the same way. type: The diagram type, as in diagram(). Ignored when x is already an IR string.

Returns

graphviz.Source or SVGString A graphviz.Source for the digraph views; an :class:SVGString for the chart views.

Source code in src/theoryforge/render.py
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
def render_diagram(x, type: str = "nomological_net"):
    """Render a digraph view without leaving Python.

    Where :func:`theoryforge.diagram.diagram` returns the deterministic Graphviz
    DOT string, ``render_diagram`` wraps it in a ``graphviz.Source``, which
    displays inline in Jupyter and renders to a file with its ``render`` method.
    Requires the optional `graphviz <https://pypi.org/project/graphviz/>`_
    library (``pip install "theoryforge[render]"``) and, to write image files, the
    Graphviz system binaries.

    The three chart views (``venn``, ``rigour`` and ``severity``) are already
    SVG, so they come back as an :class:`SVGString`, which also displays inline
    in Jupyter. The ``causal_dag`` view emits dagitty syntax rather than DOT, so
    it is not rendered here; paste it into a dagitty tool instead.

    Parameters
    ----------
    x:
        A :class:`~theoryforge.core.Theory`, a parsed theory mapping, or a
        diagram IR string from ``diagram()`` or ``lit_diagram()``, so literature
        diagrams render the same way.
    type:
        The diagram type, as in ``diagram()``. Ignored when ``x`` is already an
        IR string.

    Returns
    -------
    graphviz.Source or SVGString
        A ``graphviz.Source`` for the digraph views; an :class:`SVGString` for
        the chart views.
    """
    if isinstance(x, str):
        ir = x
        if ir.lstrip().startswith("<svg"):
            return SVGString(ir)
        if ir.lstrip().startswith("dag"):
            raise ValueError(
                "this is dagitty syntax (the causal_dag view), not Graphviz DOT; "
                "render it with a dagitty tool such as dagitty.net."
            )
    else:
        if type == "causal_dag":
            raise ValueError(
                "the causal_dag view emits dagitty syntax, not Graphviz DOT; "
                "render diagram(x, 'causal_dag') with a dagitty tool such as "
                "dagitty.net."
            )
        if type in _SVG_TYPES:
            return SVGString(_diagram(x, type=type))
        if type not in _DOT_TYPES:
            # Delegate to diagram() for its canonical unknown-type error.
            _diagram(x, type=type)
        ir = _diagram(x, type=type)

    try:
        import graphviz
    except ImportError as err:
        # Unquoted, the brackets are a filename pattern to zsh, and cmd passes single
        # quotes on to pip, so the extra is double-quoted.
        raise ImportError(
            "render_diagram() needs the optional 'graphviz' library; install it "
            'with pip install "theoryforge[render]", or use diagram() and render '
            "the DOT string with any Graphviz tool."
        ) from err
    return graphviz.Source(ir)

Develop

Appraise an amendment to a theory as progressive or degenerating, in Lakatos's sense.

theoryforge.appraise_amendment(new, prior)

Appraise an amendment as progressive, degenerating, or neutral relative to a prior version.

The two versions are compared by content. A new prediction whose statement and type match a prediction the amendment dropped is that prediction renamed. A new prediction derived only from propositions the prior already held, and needing no new assumption, articulates old content. A prediction is corroborated when at least one test outcome passes it and none fails it. An assumption added for an anomaly (its added_for names the prediction it answers) is ad hoc unless a prediction it protects that is new in this version, other than the anomaly, is corroborated.

The verdict is progressive when a corroborated new prediction is neither an articulation nor underived, no assumption is ad hoc and no corroborated prediction is dropped. It is degenerating when an assumption is ad hoc and no such prediction exists, and neutral otherwise. Lakatos has only the first two. neutral is theoryforge's label for an amendment that meets neither rule, and the lists returned tell its cases apart.

Returns:

Type Description
dict

A dict whose keys follow API_SPEC.md section 10, with verdict

dict

first and the four keys of 0.6.0 before the rest. new_predictions,

dict

corroborated_new, ad_hoc_assumptions, articulated,

dict

underived, corroborated_new_registered, dropped,

dict

dropped_corroborated, content_lost and new_anomalies are

dict

sorted id lists. renamed holds {prior, new} pairs sorted by

dict

new, and assumptions one ``{id, added_for, class,

dict

independent}`` record per new assumption added for an anomaly, in

dict

file order. Only corroborated_new_registered reads registered,

dict

and no date is read, so which version is the prior is the caller's

dict

responsibility.

Raises:

Type Description
ValueError

new and prior are the same object. The builders work in place, so new = prior.add_prediction(...) makes the two one object, which can only be appraised as neutral. Begin the amendment with prior.copy(). Two distinct objects with equal content are appraised as usual.

ValueError

a test outcome's passed is present and not a boolean, in new (checked first) or in prior. A quoted "true" read as a failure and could turn a progressive amendment into a degenerating one.

References

Lakatos, I. (1970). Falsification and the methodology of scientific research programmes. In Criticism and the growth of knowledge (pp. 91-196). Cambridge University Press. https://doi.org/10.1017/cbo9781139171434.009 Meehl, P. E. (1990a). Appraising and amending theories: The strategy of Lakatosian defense and two principles that warrant it. Psychological Inquiry, 1(2), 108-141. https://doi.org/10.1207/s15327965pli0102_1

Source code in src/theoryforge/develop.py
 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
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
def appraise_amendment(new, prior) -> dict:
    """Appraise an amendment as progressive, degenerating, or neutral relative to a prior version.

    The two versions are compared by content. A new prediction whose statement
    and type match a prediction the amendment dropped is that prediction
    renamed. A new prediction derived only from propositions the prior already
    held, and needing no new assumption, articulates old content. A prediction
    is corroborated when at least one test outcome passes it and none fails
    it. An assumption added for an anomaly (its ``added_for`` names the
    prediction it answers) is ad hoc unless a prediction it protects that is
    new in this version, other than the anomaly, is corroborated.

    The verdict is ``progressive`` when a corroborated new prediction is
    neither an articulation nor underived, no assumption is ad hoc and no
    corroborated prediction is dropped. It is ``degenerating`` when an
    assumption is ad hoc and no such prediction exists, and ``neutral``
    otherwise. Lakatos has only the first two. ``neutral`` is theoryforge's
    label for an amendment that meets neither rule, and the lists returned
    tell its cases apart.

    Returns:
        A dict whose keys follow API_SPEC.md section 10, with ``verdict``
        first and the four keys of 0.6.0 before the rest. ``new_predictions``,
        ``corroborated_new``, ``ad_hoc_assumptions``, ``articulated``,
        ``underived``, ``corroborated_new_registered``, ``dropped``,
        ``dropped_corroborated``, ``content_lost`` and ``new_anomalies`` are
        sorted id lists. ``renamed`` holds ``{prior, new}`` pairs sorted by
        ``new``, and ``assumptions`` one ``{id, added_for, class,
        independent}`` record per new assumption added for an anomaly, in
        file order. Only ``corroborated_new_registered`` reads ``registered``,
        and no date is read, so which version is the prior is the caller's
        responsibility.

    Raises:
        ValueError: ``new`` and ``prior`` are the same object. The builders
            work in place, so ``new = prior.add_prediction(...)`` makes the two
            one object, which can only be appraised as ``neutral``. Begin the
            amendment with ``prior.copy()``. Two distinct objects with equal
            content are appraised as usual.
        ValueError: a test outcome's ``passed`` is present and not a boolean,
            in ``new`` (checked first) or in ``prior``. A quoted ``"true"``
            read as a failure and could turn a progressive amendment into a
            degenerating one.

    References:
        Lakatos, I. (1970). Falsification and the methodology of scientific
        research programmes. In Criticism and the growth of knowledge
        (pp. 91-196). Cambridge University Press.
        https://doi.org/10.1017/cbo9781139171434.009
        Meehl, P. E. (1990a). Appraising and amending theories: The strategy of
        Lakatosian defense and two principles that warrant it. Psychological
        Inquiry, 1(2), 108-141. https://doi.org/10.1207/s15327965pli0102_1
    """
    new = new.data if hasattr(new, "data") else new
    prior = prior.data if hasattr(prior, "data") else prior
    # Only identity is refused. Equal content in two objects is a legitimate
    # comparison, which R appraises as neutral, so it must pass here too.
    if new is prior:
        raise ValueError(
            "appraise_amendment needs two distinct theory objects, but the amendment and "
            "the prior are the same object. The Python builders change a theory in place, "
            "so start the amendment from prior.copy()."
        )
    _refuse_non_boolean_outcomes(new, "appraise_amendment")
    _refuse_non_boolean_outcomes(prior, "appraise_amendment")

    # A missing id reads as "" (API_SPEC.md section 3, "Reading a theory"), as
    # in R. validate() reports the entry; the appraisal still runs.
    new_tos, prior_tos = items(new, "test_outcomes"), items(prior, "test_outcomes")
    new_preds, prior_preds = items(new, "predictions"), items(prior, "predictions")
    new_ids = [text(field(p, "id")) for p in new_preds]
    prior_ids = [text(field(p, "id")) for p in prior_preds]

    # A prediction under an id the prior lacks takes the first prior prediction
    # that makes the same claim, under an id the amendment dropped. A blank
    # statement claims nothing, so it is never matched.
    prior_keys = [content_key(p) for p in prior_preds]
    taken = [False] * len(prior_preds)
    renamed: list[dict[str, str]] = []
    for pid, p in zip(new_ids, new_preds, strict=True):
        key = content_key(p)
        if pid in prior_ids or not key[0]:
            continue
        for j, qid in enumerate(prior_ids):
            if not taken[j] and qid not in new_ids and prior_keys[j] == key:
                taken[j] = True
                renamed.append({"prior": qid, "new": pid})
                break
    renamed_new = {r["new"] for r in renamed}
    renamed_prior = {r["prior"] for r in renamed}

    new_predictions = [pid for pid in new_ids if pid not in prior_ids and pid not in renamed_new]
    corroborated_new = [pid for pid in new_predictions if prediction_status(pid, new_tos) == CORROBORATED]

    # Old content: a proposition whose endpoints and relation the prior holds,
    # under any id, so renaming a proposition adds nothing either.
    prior_aux_ids = {text(field(a, "id")) for a in items(prior, "auxiliary_assumptions")}
    new_aux = [a for a in items(new, "auxiliary_assumptions") if text(field(a, "id")) not in prior_aux_ids]
    protected = {q for a in new_aux for q in str_list(field(a, "protects"))}
    old_content = {_proposition_content(p) for p in items(prior, "propositions")}
    content_of: dict[str, tuple[str, str, str]] = {}
    for p in items(new, "propositions"):
        content_of.setdefault(text(field(p, "id")), _proposition_content(p))
    articulated: list[str] = []
    underived: list[str] = []
    for pid, p in zip(new_ids, new_preds, strict=True):
        if pid not in new_predictions:
            continue
        sources = str_list(field(p, "derives_from"))
        if not sources:
            underived.append(pid)
        elif pid not in protected and all(content_of.get(d) in old_content for d in sources):
            articulated.append(pid)

    dropped = [pid for pid in prior_ids if pid not in new_ids and pid not in renamed_prior]
    dropped_corroborated = [pid for pid in dropped if prediction_status(pid, prior_tos) == CORROBORATED]
    content_lost = [pid for pid in dropped if prediction_status(pid, prior_tos) == "untested"]

    retained = [(pid, pid) for pid in new_ids if pid in prior_ids]
    retained += [(r["prior"], r["new"]) for r in renamed]
    new_anomalies = [now for was, now in retained
                     if prediction_status(now, new_tos) in ("refuted", "mixed")
                     and prediction_status(was, prior_tos) in (CORROBORATED, "untested")]

    assumptions: list[dict] = []
    ad_hoc: list[str] = []
    for a in new_aux:
        added_for = text(field(a, "added_for"))
        if not ne_str(added_for):
            continue
        aid = text(field(a, "id"))
        cls, independent = classify_auxiliary(a, new_tos, new_predictions)
        assumptions.append({"id": aid, "added_for": added_for, "class": cls, "independent": independent})
        if cls != "independently_corroborated":
            ad_hoc.append(aid)

    registered = [pid for pid in corroborated_new if any(_registered_pass(t, pid) for t in new_tos)]

    adds_content = [pid for pid in corroborated_new if pid not in articulated and pid not in underived]
    if adds_content and not ad_hoc and not dropped_corroborated:
        verdict = "progressive"
    elif ad_hoc and not adds_content:
        verdict = "degenerating"
    else:
        verdict = "neutral"

    return {
        "verdict": verdict,
        "new_predictions": sorted(new_predictions),
        "corroborated_new": sorted(corroborated_new),
        "ad_hoc_assumptions": sorted(ad_hoc),
        "articulated": sorted(articulated),
        "underived": sorted(underived),
        "corroborated_new_registered": sorted(registered),
        "renamed": sorted(renamed, key=lambda r: r["new"]),
        "dropped": sorted(dropped),
        "dropped_corroborated": sorted(dropped_corroborated),
        "content_lost": sorted(content_lost),
        "new_anomalies": sorted(new_anomalies),
        "assumptions": assumptions,
    }

Testing and review

The conditional independencies a causal theory commits to, preregistration, SEM compilation and the reviewer-facing audit dossier.

theoryforge.implications.implications(T, cycles='refuse')

Conditional independencies implied by the graph a theory's propositions state.

Reads every proposition through the relation table (API_SPEC.md section 28). A directed relation (increases, decreases, causes, mediates or moderates) is an edge from from to to, and associates is a bidirected edge, covariance the theory leaves unexplained. The constructs that a directed relation names are the vertices, and an association is an edge only between two of them. The function then takes every pair of vertices that no edge joins. The pair is stated independent given the parents of both when those m-separate it (Richardson, 2003), and otherwise given all the other ancestors of the two when those do. A pair that neither set separates is separated by no set of constructs (Richardson & Spirtes, 2002, Theorem 4.2), so the theory implies no independence for it, and it is listed under inseparable. When every relation is directed and the graph is acyclic, the statements are the basis set of Pearl (1988) and Shipley (2000), from which every other independence the graph implies follows.

cycles says what to do with a graph whose directed edges form a cycle. The default, "refuse", raises an error that names a cycle found. "sigma" reads the graph by sigma-separation (Bongers et al., 2021), the criterion that holds when each feedback loop, a strongly connected component of the directed edges, has a unique equilibrium. The graph is then replaced by its acyclification (Definition A.13): every member of a loop takes the parents of the whole loop from outside it, and the members of a loop are joined to each other by bidirected edges. Each pair the theory leaves unjoined is then tested in that graph as above, where m-separation equals sigma-separation in the theory's own graph (Proposition A.19). There, two members of one loop are always joined. So is a construct outside a loop to every member, when it enters one of them or when it or a member of its own loop is associated with one of them. No set separates such a pair, and when the theory itself leaves the pair unjoined, it is listed under inseparable. A theory that posits alternative stable states, such as a bistable network, has more than one equilibrium and violates the assumption, and its sigma statements are not guaranteed. On an acyclic graph, the two options give the same statements. Applying d-separation to a cyclic graph, as dagitty does with the causal_dag export, is valid only in special cases such as a linear model.

Returns {theory_id, criterion, acyclic, constructs, n_edges, n_bidirected, feedback, implications, n_implications, inseparable}. criterion names the separation criterion, "m" under "refuse" and "sigma" under "sigma". constructs lists the vertices in file order. Constructs that no directed relation names are left out, because silence about a construct is not a claim that it is independent of anything, and so are constructs without an id, which no proposition can name. A construct named only by associations would be a collider on every path through it, so leaving it out loses no statement about the others. n_edges counts the directed edges the theory states and n_bidirected the bidirected ones, a pair stated twice counting once, before any acyclification. feedback lists the feedback loops, each the ids of its members in file order and the loops in the order of their first members. A loop has two or more members, or one construct with an edge to itself. acyclic is True when feedback is empty, which is always the case under "refuse", and both are carried so that a serialised record states the verdict. Each entry of implications is {a, b, given, statement}, where statement renders the claim in the notation dagitty prints, a _||_ b | z1, z2, and each entry of inseparable is {a, b}. Pairs come in construct file order, as do the members of given, so the two engines return the same records in the same order.

The statements concern constructs, and a study measures them with error. Error in a conditioning construct leaves part of the dependence that holding it fixed should remove. A conditional statement tested on observed scores is therefore rejected too often, and more often the larger the sample (Westfall & Yarkoni, 2016). Take a chain whose two paths have standardised coefficients of .5, with the middle construct measured at a reliability of .8. A partial-correlation test at the 5 per cent level then rejects the true statement in about 14, 29 and 51 per cent of studies of 200, 500 and 1,000 observations. Conditional statements are better tested with latent-variable models, such as one built on the measurement model that compile_sem() writes (Thoemmes et al., 2018).

A theory with no directed relations comes back with no vertices and no error: constructs, implications and inseparable are empty and n_implications is 0.

Raises:

Type Description
ValueError

if cycles is not "refuse" or "sigma", which is checked before the theory is read, if two constructs share an id or if a directed relation names a construct the theory has not declared. Under "refuse" it is also raised when the directed edges form a cycle, and the message then names a cycle found and the "sigma" option.

References

Bongers, S., Forré, P., Peters, J., & Mooij, J. M. (2021). Foundations of structural causal models with cycles and latent variables. The Annals of Statistics, 49(5), 2885-2915. https://doi.org/10.1214/21-AOS2064 Pearl, J. (1988). Probabilistic reasoning in intelligent systems: Networks of plausible inference. Morgan Kaufmann. Richardson, T. (2003). Markov properties for acyclic directed mixed graphs. Scandinavian Journal of Statistics, 30(1), 145-157. https://doi.org/10.1111/1467-9469.00323 Richardson, T., & Spirtes, P. (2002). Ancestral graph Markov models. The Annals of Statistics, 30(4), 962-1030. https://doi.org/10.1214/aos/1031689015 Shipley, B. (2000). A new inferential test for path models based on directed acyclic graphs. Structural Equation Modeling, 7(2), 206-218. https://doi.org/10.1207/S15328007SEM0702_4 Thoemmes, F., Rosseel, Y., & Textor, J. (2018). Local fit evaluation of structural equation models using graphical criteria. Psychological Methods, 23(1), 27-41. https://doi.org/10.1037/met0000147 Westfall, J., & Yarkoni, T. (2016). Statistically controlling for confounding constructs is harder than you think. PLOS ONE, 11(3), e0152719. https://doi.org/10.1371/journal.pone.0152719

Example

A mediated chain, arousal raising perceived threat and perceived threat raising avoidance, commits the theory to one thing it does not state directly, that arousal and avoidance are independent once perceived threat is held fixed.

import theoryforge as tf

t = tf.new_theory("mediation", "A mediated chain")
t.add_construct("c_arousal", "Arousal", "Bodily activation.")
t.add_construct("c_threat", "Perceived threat", "Appraised danger.")
t.add_construct("c_avoidance", "Avoidance", "Withdrawal from the trigger.")
t.add_proposition("p1", "c_arousal", "c_threat", "increases")
t.add_proposition("p2", "c_threat", "c_avoidance", "increases")

[i["statement"] for i in t.implications()["implications"]]
# ['c_arousal _||_ c_avoidance | c_threat']

Stating that arousal and avoidance also covary for reasons the theory leaves open (t.add_proposition("p3", "c_arousal", "c_avoidance", "associates")) withdraws that claim: the pair is then joined by an edge, and nothing is implied.

Closing a feedback loop from perceived threat back to arousal makes the graph cyclic. The default refuses it, and sigma-separation keeps the claim, since holding perceived threat fixed still cuts the one way out of the loop towards avoidance.

t.add_proposition("p3", "c_threat", "c_arousal", "causes")

implied = t.implications(cycles="sigma")
implied["feedback"]
# [['c_arousal', 'c_threat']]
[i["statement"] for i in implied["implications"]]
# ['c_arousal _||_ c_avoidance | c_threat']
Source code in src/theoryforge/implications.py
 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
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
def implications(T, cycles: str = "refuse") -> dict:
    """Conditional independencies implied by the graph a theory's propositions state.

    Reads every proposition through the relation table (API_SPEC.md section
    28). A directed relation (``increases``, ``decreases``, ``causes``,
    ``mediates`` or ``moderates``) is an edge from ``from`` to ``to``, and
    ``associates`` is a bidirected edge, covariance the theory leaves
    unexplained. The constructs that a directed relation names are the
    vertices, and an association is an edge only between two of them. The
    function then takes every pair of vertices that no edge joins. The pair is
    stated independent given the parents of both when those m-separate it
    (Richardson, 2003), and otherwise given all the other ancestors of the two
    when those do. A pair that neither set separates is separated by no set of
    constructs (Richardson & Spirtes, 2002, Theorem 4.2), so the theory
    implies no independence for it, and it is listed under ``inseparable``.
    When every relation is directed and the graph is acyclic, the statements
    are the basis set of Pearl (1988) and Shipley (2000), from which every
    other independence the graph implies follows.

    ``cycles`` says what to do with a graph whose directed edges form a cycle.
    The default, ``"refuse"``, raises an error that names a cycle found.
    ``"sigma"`` reads the graph by sigma-separation (Bongers et al., 2021),
    the criterion that holds when each feedback loop, a strongly connected
    component of the directed edges, has a unique equilibrium. The graph is
    then replaced by its acyclification (Definition A.13): every member of a
    loop takes the parents of the whole loop from outside it, and the members
    of a loop are joined to each other by bidirected edges. Each pair the
    theory leaves unjoined is then tested in that graph as above, where
    m-separation equals sigma-separation in the theory's own graph
    (Proposition A.19). There, two members of one loop are always joined. So
    is a construct outside a loop to every member, when it enters one of them
    or when it or a member of its own loop is associated with one of them. No
    set separates such a pair, and when the theory itself leaves the pair
    unjoined, it is listed under ``inseparable``. A theory that posits
    alternative stable states, such as a bistable network, has more than one
    equilibrium and violates the assumption, and its sigma statements are not
    guaranteed. On an acyclic graph, the two options give the same statements.
    Applying d-separation to a cyclic graph, as dagitty does with the
    causal_dag export, is valid only in special cases such as a linear model.

    Returns ``{theory_id, criterion, acyclic, constructs, n_edges,
    n_bidirected, feedback, implications, n_implications, inseparable}``.
    ``criterion`` names the separation criterion, ``"m"`` under ``"refuse"``
    and ``"sigma"`` under ``"sigma"``. ``constructs`` lists the vertices in
    file order. Constructs that no directed relation names are left out,
    because silence about a construct is not a claim that it is independent of
    anything, and so are constructs without an id, which no proposition can
    name. A construct named only by associations would be a collider on every
    path through it, so leaving it out loses no statement about the others.
    ``n_edges`` counts the directed edges the theory states and
    ``n_bidirected`` the bidirected ones, a pair stated twice counting once,
    before any acyclification. ``feedback`` lists the feedback loops, each the
    ids of its members in file order and the loops in the order of their first
    members. A loop has two or more members, or one construct with an edge to
    itself. ``acyclic`` is True when ``feedback`` is empty, which is always the
    case under ``"refuse"``, and both are carried so that a serialised record
    states the verdict. Each entry of ``implications`` is ``{a, b, given,
    statement}``, where ``statement`` renders the claim in the notation
    dagitty prints, ``a _||_ b | z1, z2``, and each entry of ``inseparable``
    is ``{a, b}``. Pairs come in construct file order, as do the members of
    ``given``, so the two engines return the same records in the same order.

    The statements concern constructs, and a study measures them with error.
    Error in a conditioning construct leaves part of the dependence that
    holding it fixed should remove. A conditional statement tested on observed
    scores is therefore rejected too often, and more often the larger the
    sample (Westfall & Yarkoni, 2016). Take a chain whose two paths have
    standardised coefficients of .5, with the middle construct measured at a
    reliability of .8. A partial-correlation test at the 5 per cent level then
    rejects the true statement in about 14, 29 and 51 per cent of studies of
    200, 500 and 1,000 observations. Conditional statements are better tested
    with latent-variable models, such as one built on the measurement model
    that ``compile_sem()`` writes (Thoemmes et al., 2018).

    A theory with no directed relations comes back with no vertices and no
    error: ``constructs``, ``implications`` and ``inseparable`` are empty and
    ``n_implications`` is 0.

    Raises:
        ValueError: if ``cycles`` is not ``"refuse"`` or ``"sigma"``, which is
            checked before the theory is read, if two constructs share an id
            or if a directed relation names a construct the theory has not
            declared. Under ``"refuse"`` it is also raised when the directed
            edges form a cycle, and the message then names a cycle found and
            the ``"sigma"`` option.

    References:
        Bongers, S., Forré, P., Peters, J., & Mooij, J. M. (2021). Foundations
        of structural causal models with cycles and latent variables. The
        Annals of Statistics, 49(5), 2885-2915.
        https://doi.org/10.1214/21-AOS2064
        Pearl, J. (1988). Probabilistic reasoning in intelligent systems:
        Networks of plausible inference. Morgan Kaufmann.
        Richardson, T. (2003). Markov properties for acyclic directed mixed
        graphs. Scandinavian Journal of Statistics, 30(1), 145-157.
        https://doi.org/10.1111/1467-9469.00323
        Richardson, T., & Spirtes, P. (2002). Ancestral graph Markov models.
        The Annals of Statistics, 30(4), 962-1030.
        https://doi.org/10.1214/aos/1031689015
        Shipley, B. (2000). A new inferential test for path models based on
        directed acyclic graphs. Structural Equation Modeling, 7(2), 206-218.
        https://doi.org/10.1207/S15328007SEM0702_4
        Thoemmes, F., Rosseel, Y., & Textor, J. (2018). Local fit evaluation of
        structural equation models using graphical criteria. Psychological
        Methods, 23(1), 27-41. https://doi.org/10.1037/met0000147
        Westfall, J., & Yarkoni, T. (2016). Statistically controlling for
        confounding constructs is harder than you think. PLOS ONE, 11(3),
        e0152719. https://doi.org/10.1371/journal.pone.0152719

    Example:
        A mediated chain, arousal raising perceived threat and perceived threat
        raising avoidance, commits the theory to one thing it does not state
        directly, that arousal and avoidance are independent once perceived
        threat is held fixed.

        ```python
        import theoryforge as tf

        t = tf.new_theory("mediation", "A mediated chain")
        t.add_construct("c_arousal", "Arousal", "Bodily activation.")
        t.add_construct("c_threat", "Perceived threat", "Appraised danger.")
        t.add_construct("c_avoidance", "Avoidance", "Withdrawal from the trigger.")
        t.add_proposition("p1", "c_arousal", "c_threat", "increases")
        t.add_proposition("p2", "c_threat", "c_avoidance", "increases")

        [i["statement"] for i in t.implications()["implications"]]
        # ['c_arousal _||_ c_avoidance | c_threat']
        ```

        Stating that arousal and avoidance also covary for reasons the theory
        leaves open (``t.add_proposition("p3", "c_arousal", "c_avoidance",
        "associates")``) withdraws that claim: the pair is then joined by an
        edge, and nothing is implied.

        Closing a feedback loop from perceived threat back to arousal makes the
        graph cyclic. The default refuses it, and sigma-separation keeps the
        claim, since holding perceived threat fixed still cuts the one way
        out of the loop towards avoidance.

        ```python
        t.add_proposition("p3", "c_threat", "c_arousal", "causes")

        implied = t.implications(cycles="sigma")
        implied["feedback"]
        # [['c_arousal', 'c_threat']]
        [i["statement"] for i in implied["implications"]]
        # ['c_arousal _||_ c_avoidance | c_threat']
        ```
    """
    # Checked before the theory is read, so that a mistyped option is reported
    # as such whatever the theory holds.
    if not isinstance(cycles, str) or cycles not in CYCLES:
        raise ValueError("implications requires cycles to be 'refuse' or 'sigma'")
    T = T.data if hasattr(T, "data") else T

    declared: list[str] = []
    position: dict[str, int] = {}
    for c in items(T, "constructs"):
        cid = text(field(c, "id"))
        # A construct without an id cannot be the endpoint of a proposition, so
        # it takes no part in the graph, and two of them do not share an id.
        if cid == "":
            continue
        # Two constructs sharing an id give the same node two sets of parents,
        # and nothing in the maths says which one a proposition meant.
        if cid in position:
            raise ValueError(
                f"implications requires unique construct ids; duplicate construct id: {cid}")
        position[cid] = len(declared)
        declared.append(cid)

    edges: list[tuple[int, int]] = []
    associations: list[tuple[str, str]] = []
    for p in items(T, "propositions"):
        rel = enum(field(p, "relation"), RELATION)
        frm, to = text(field(p, "from")), text(field(p, "to"))
        if rel in BIDIRECTED:
            associations.append((frm, to))
            continue
        if rel not in DIRECTED:
            continue
        pid = text(field(p, "id"))
        # Dropping an edge whose endpoint was never declared would shrink the
        # graph and so add independencies the theory does not imply, which is a
        # confidently wrong answer rather than a missing one.
        for endpoint in (frm, to):
            if endpoint not in position:
                raise ValueError(
                    "implications requires causal propositions between declared constructs; "
                    f"proposition '{pid}' refers to unknown construct '{endpoint}'")
        e = (position[frm], position[to])
        if e not in edges:
            edges.append(e)

    used = sorted({i for e in edges for i in e})
    nodes = [declared[i] for i in used]
    k = len(nodes)
    rank = {i: r for r, i in enumerate(used)}
    directed = [[False] * k for _ in range(k)]
    for u, v in edges:
        directed[rank[u]][rank[v]] = True
    # An association is an edge only between two vertices. A construct that
    # only associations name is a collider on every path through it, so it
    # closes them all and leaving it out changes no statement about the others.
    vertex = {cid: r for r, cid in enumerate(nodes)}
    bidirected = [[False] * k for _ in range(k)]
    n_bidirected = 0
    for frm, to in associations:
        a, b = vertex.get(frm), vertex.get(to)
        if a is None or b is None or a == b or bidirected[a][b]:
            continue
        bidirected[a][b] = bidirected[b][a] = True
        n_bidirected += 1

    # The pairs are those the theory leaves unjoined. Under "sigma", the
    # acyclification below can join more of them, and those are then separated
    # by no set and listed as inseparable, not dropped.
    adjacent = [[directed[i][j] or directed[j][i] or bidirected[i][j] for j in range(k)]
                for i in range(k)]
    criterion = "m"
    feedback: list[list[str]] = []
    if cycles == "refuse":
        cycle = first_cycle(directed, k)
        if cycle is not None:
            raise ValueError(
                "implications requires an acyclic causal graph; cycle found: "
                + " -> ".join(nodes[i] for i in cycle)
                + "; set cycles to 'sigma' to derive sigma-separation statements")
    else:
        # A feedback loop is a component of two or more vertices, or one with a
        # self loop. The pairs are then read off the acyclification, where
        # m-separation is sigma-separation in the theory's own graph (Bongers
        # et al., 2021, Proposition A.19). The counts and the vertices above
        # stay those of the theory's graph.
        comps = strong_components(reach(directed, k))
        feedback = [[nodes[v] for v in comp] for comp in comps
                    if len(comp) > 1 or directed[comp[0]][comp[0]]]
        directed, bidirected = acyclify(directed, bidirected, comps)
        criterion = "sigma"

    r = reach(directed, k)
    out: list[dict] = []
    inseparable: list[dict] = []
    for i in range(k):
        for j in range(i + 1, k):
            if adjacent[i][j]:
                continue
            # The parents of the pair separate it in every DAG, so a theory of
            # directed relations alone gets the basis set. With bidirected
            # edges, a parent can be a collider that holding it fixed opens,
            # and then the ancestors of the pair are tried. When they fail,
            # every vertex on a path that connects the two is a collider and an
            # ancestor of one of them, an inducing path, so no set separates
            # the pair (API_SPEC.md section 27).
            parents = [g for g in range(k) if directed[g][i] or directed[g][j]]
            ancestors = [g for g in range(k) if (r[g][i] or r[g][j]) and g not in (i, j)]
            given = next((s for s in (parents, ancestors)
                          if m_separated(directed, bidirected, r, i, j, s)), None)
            if given is None:
                inseparable.append({"a": nodes[i], "b": nodes[j]})
                continue
            names = [nodes[g] for g in given]
            out.append({"a": nodes[i], "b": nodes[j], "given": names,
                        "statement": _statement(nodes[i], nodes[j], names)})

    return {
        "theory_id": text(T.get("id")),
        "criterion": criterion,
        "acyclic": not feedback,
        "constructs": nodes,
        "n_edges": len(edges),
        "n_bidirected": n_bidirected,
        "feedback": feedback,
        "implications": out,
        "n_implications": len(out),
        "inseparable": inseparable,
    }

theoryforge.preregister(T, path=None)

Render the hypotheses section of a preregistration, writing it to path when one is given.

The header gives the theory id, the version when it has an id (with its parent, when one is named), the schema version, the maturity and whether every prediction's derivation references resolve: yes, no or not applicable (no predictions). The derivation line reports the checklist's reference check, which does not show that a prediction follows from the propositions it cites.

Each hypothesis is listed in file order with its claim type, statement and derivation, followed by the auxiliary assumptions that protect it (core or added for <id>), the registered rivals it discriminates and its test record. A hypothesis that a recorded outcome has already tested reads already tested: passed (<date>, registered <where>), one entry per outcome, and any other reads untested.

When at least one prediction states a sampling plan in its test block, a section headed Sampling plan (author-recorded; not scored) follows, with one line per prediction in file order. A line gives what is recorded of the planned sample, alpha and sidedness, smallest effect of interest, power or decision probability, design analysis and justification. A prediction without a plan reads no sampling plan stated. Nothing scores the plan: power does not raise a claim's severity (Meehl, 1967), though it decides how informative a failed test is (Lakens, 2022). A theory without a plan has no such section.

A section then lists the conditional independencies that implications() derives from the causal graph with its default settings, which are claims the theory makes without stating them, or says why there are none. The claim-form severity rubric closes the document.

The document is the part of a preregistration that the theory file holds. It is to be completed with the study design, the analysis plan, the inference criteria and whatever the sampling plan leaves out, the sections that a template such as PRP-QUANT asks for (Bosnjak et al., 2022).

References

Bosnjak, M., Fiebach, C. J., Mellor, D., Mueller, S., O'Connor, D. B., Oswald, F. L., & Sokol, R. I. (2022). A template for preregistration of quantitative research in psychology: Report of the joint psychological societies preregistration task force. American Psychologist, 77(4), 602-615. https://doi.org/10.1037/amp0000879

Lakens, D. (2022). Sample size justification. Collabra: Psychology, 8(1), Article 33267. https://doi.org/10.1525/collabra.33267

Meehl, P. E. (1967). Theory-testing in psychology and physics: A methodological paradox. Philosophy of Science, 34(2), 103-115. https://doi.org/10.1086/288135

Source code in src/theoryforge/prereg.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
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
def preregister(T, path=None) -> str:
    """Render the hypotheses section of a preregistration, writing it to ``path`` when one is given.

    The header gives the theory id, the version when it has an id (with its
    parent, when one is named), the schema version, the maturity and whether
    every prediction's derivation references resolve: ``yes``, ``no`` or ``not
    applicable (no predictions)``. The derivation line reports the checklist's
    reference check, which does not show that a prediction follows from the
    propositions it cites.

    Each hypothesis is listed in file order with its claim type, statement and
    derivation, followed by the auxiliary assumptions that protect it (``core``
    or ``added for <id>``), the registered rivals it discriminates and its test
    record. A hypothesis that a recorded outcome has already tested reads
    ``already tested: passed (<date>, registered <where>)``, one entry per
    outcome, and any other reads ``untested``.

    When at least one prediction states a sampling plan in its ``test`` block,
    a section headed ``Sampling plan (author-recorded; not scored)`` follows,
    with one line per prediction in file order. A line gives what is recorded
    of the planned sample, alpha and sidedness, smallest effect of interest,
    power or decision probability, design analysis and justification. A
    prediction without a plan reads ``no sampling plan stated``. Nothing
    scores the plan: power does not raise a claim's severity (Meehl, 1967),
    though it decides how informative a failed test is (Lakens, 2022). A theory
    without a plan has no such section.

    A section then lists the conditional independencies that
    ``implications()`` derives from the causal graph with its default
    settings, which are claims the theory makes without stating them, or says
    why there are none. The claim-form severity rubric closes the document.

    The document is the part of a preregistration that the theory file holds.
    It is to be completed with the study design, the analysis plan, the
    inference criteria and whatever the sampling plan leaves out, the sections
    that a template such as PRP-QUANT asks for (Bosnjak et al., 2022).

    References:
        Bosnjak, M., Fiebach, C. J., Mellor, D., Mueller, S., O'Connor, D. B.,
        Oswald, F. L., & Sokol, R. I. (2022). A template for preregistration of
        quantitative research in psychology: Report of the joint psychological
        societies preregistration task force. American Psychologist, 77(4),
        602-615. https://doi.org/10.1037/amp0000879

        Lakens, D. (2022). Sample size justification. Collabra: Psychology,
        8(1), Article 33267. https://doi.org/10.1525/collabra.33267

        Meehl, P. E. (1967). Theory-testing in psychology and physics: A
        methodological paradox. Philosophy of Science, 34(2), 103-115.
        https://doi.org/10.1086/288135
    """
    data = T.data if hasattr(T, "data") else T
    rep = _check(data)
    deriv = next((it for it in rep["items"] if it["id"] == "derivation_chain"), None)
    resolve = _RESOLVE[deriv["status"]] if deriv else "no"

    lines = [
        f"# Preregistration: {text(data.get('title'))}",
        "",
        f"- Theory ID: {text(data.get('id'))}",
        *_version_line(data),
        f"- Schema version: {text(data.get('schema_version'))}",
        f"- Maturity: {enum(data.get('maturity'), MATURITY) or ''}",
        f"- Derivation references resolve: {resolve}",
        "",
        "## Hypotheses",
    ]
    preds = items(data, "predictions")
    if not preds:
        lines.append("_No predictions specified._")
    else:
        aux = items(data, "auxiliary_assumptions")
        tos = items(data, "test_outcomes")
        alt_ids = {text(field(a, "id")) for a in items(data, "alternatives")}
        for i, p in enumerate(preds, start=1):
            df = str_list(field(p, "derives_from"))
            df_txt = ", ".join(df) if df else "—"
            ptype = enum(field(p, "type"), PRED_TYPE) or ""
            lines.append(f"{i}. [{ptype}] {text(field(p, 'statement'))} (derives from: {df_txt})")
            # Markdown nests a line under a numbered item only when it is
            # indented by the width of the number, its full stop and the space.
            pad = " " * (len(str(i)) + 2)
            pid = text(field(p, "id"))
            assumptions = _auxiliaries(pid, aux)
            if assumptions:
                lines.append(f"{pad}- Auxiliary assumptions: {', '.join(assumptions)}")
            named = str_list(field(p, "diagnostic_vs"))
            rivals = list(dict.fromkeys(d for d in named if d in alt_ids))
            if rivals:
                lines.append(f"{pad}- Rivals it discriminates: {', '.join(rivals)}")
            lines.append(f"{pad}- Test record: {_test_record(pid, tos)}")
        lines += _sampling_plan(preds)

    lines += ["", _IMPLIED_HEADING, *_implied(data)]

    # The values grade the form of each claim before any data (API_SPEC section
    # 9). They say nothing of how severely a claim is tested, and the heading
    # says so.
    lines += ["", "## Severity (pre-data rubric of claim form)"]
    sev = _severity(data)
    if not sev:
        lines.append("_No predictions specified._")
    else:
        for s in sev:
            pid, cs, rk = s["prediction_id"], _fmt(s["computed_severity"]), _fmt(s["risk_score"])
            lines.append(f"- {pid}: severity {cs}, risk {rk}")

    doc = "\n".join(lines) + "\n"
    if path is not None:
        _write_lf(path, doc)
    return doc

theoryforge.compile_sem(T)

Compile a theory's constructs and propositions to lavaan model syntax.

The syntax is written for lavaan::sem(). Each construct with measurement indicators becomes a latent variable (=~). Each proposition becomes a line of the structural model, in file order: a regression (~) for causes, increases, decreases and mediates and a covariance (~~) for associates. A moderates proposition gives the moderator's main effect, to ~ from, unless a path relation already writes that line. A comment follows on the product term, which has to be added by hand: to ~ x:from for an observed predictor x, and lavaan::sam() or the modsem package when the variables are latent. The output is the same in the R twin, byte for byte.

The propositions form the graph that implications() reads. Two of its constructs that are both exogenous, with no proposition pointing into either, or both terminal, with neither pointing to another construct, are unrelated by the theory's account unless an association joins them. By default, lavaan::sem() frees the covariance of two exogenous latent variables and of two terminal variables, and takes that of two exogenous observed variables from the data. A fit with those defaults could not refute the claim. In the modality-switching example, two of the six independencies implications() derives would go untested. The syntax therefore ends with a block that fixes each such covariance at zero (a ~~ 0*b), and deleting the block restores lavaan's defaults. A pair with a moderator keeps those defaults, because the product term changes the moderator's role.

lavaan takes the covariances of observed exogenous variables from the data only until a covariance line names one of them. It then treats that variable as random and fixes at zero each of its covariances that the syntax does not write. Where a zero line names such a variable, the block therefore ends by freeing each covariance between two observed exogenous variables that no association names, when either is a moderator (x ~~ m). The moderator then keeps lavaan's defaults.

The syntax is a starting point for a fit. Indicator names are the sanitised measurement entries and must match columns of the data, so entries written as column names, such as heart_rate, give syntax that fits as it stands. A comment lists each construct with a single indicator, whose residual variance lavaan::sem() fixes at zero, so the construct is treated as measured without error. A comment flags each feedback loop. A non-recursive model may not be identified (Bollen, 1989), and the panic-network example is not, so check identification before fitting. Some constructs have to be re-specified by hand. A manipulation is better entered as a coded observed variable and a categorical predictor with more than two levels as dummy variables. A covariate that a measurement entry only describes needs a variable of its own. Once the block names an observed predictor, a product term added by hand needs a free covariance with each observed exogenous variable that a covariance line names. lavaan fixes each one left unwritten at zero, and each takes a line of its own (x:m ~~ x).

Every name is one lavaan reads as written. Construct ids that lavaan would refuse or misread, such as self-efficacy, 1arousal, NA or its own keyword efa, are renamed. A comment after the header records each renaming (# renamed for lavaan: 'self-efficacy' -> self_efficacy). An indicator is its measurement entry folded and lowercased, with each run of other characters made one underscore. i_ goes in front when the result would still be refused, so 7-point Likert rating gives i_7_point_likert_rating. A comment writes each control character of an id, and each character outside the Basic Multilingual Plane, as <U+XXXX>, so that it stays one line that lavaan reads as a comment. API_SPEC.md section 19 states the rules.

Raises:

Type Description
ValueError

when two different ids would share a name, since lavaan would merge them. Such a pair is two constructs, two indicators of one construct or a construct and an indicator. A construct and an indicator collide when the construct is measured by its own name or named like an indicator of another construct. An indicator shared by two constructs is allowed.

References

Bollen, K. A. (1989). Structural equations with latent variables. Wiley. https://doi.org/10.1002/9781118619179

Source code in src/theoryforge/sem.py
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
def compile_sem(T) -> str:
    """Compile a theory's constructs and propositions to lavaan model syntax.

    The syntax is written for ``lavaan::sem()``. Each construct with
    measurement indicators becomes a latent variable (``=~``). Each
    proposition becomes a line of the structural model, in file order: a
    regression (``~``) for ``causes``, ``increases``, ``decreases`` and
    ``mediates`` and a covariance (``~~``) for ``associates``. A
    ``moderates`` proposition gives the moderator's main effect,
    ``to ~ from``, unless a path relation already writes that line. A comment
    follows on the product term, which has to be added by hand:
    ``to ~ x:from`` for an observed predictor ``x``, and ``lavaan::sam()`` or
    the modsem package when the variables are latent. The output is the same
    in the R twin, byte for byte.

    The propositions form the graph that ``implications()`` reads. Two of its
    constructs that are both exogenous, with no proposition pointing into
    either, or both terminal, with neither pointing to another construct, are
    unrelated by the theory's account unless an association joins them. By
    default, ``lavaan::sem()`` frees the covariance of two exogenous latent
    variables and of two terminal variables, and takes that of two exogenous
    observed variables from the data. A fit with those defaults could not
    refute the claim. In the modality-switching example, two of the six
    independencies ``implications()`` derives would go untested. The syntax
    therefore ends with a block that fixes each such covariance at zero
    (``a ~~ 0*b``), and deleting the block restores lavaan's defaults. A pair
    with a moderator keeps those defaults, because the product term changes
    the moderator's role.

    lavaan takes the covariances of observed exogenous variables from the
    data only until a covariance line names one of them. It then treats that
    variable as random and fixes at zero each of its covariances that the
    syntax does not write. Where a zero line names such a variable, the block
    therefore ends by freeing each covariance between two observed exogenous
    variables that no association names, when either is a moderator
    (``x ~~ m``). The moderator then keeps lavaan's defaults.

    The syntax is a starting point for a fit. Indicator names are the
    sanitised measurement entries and must match columns of the data, so
    entries written as column names, such as ``heart_rate``, give syntax that
    fits as it stands. A comment lists each construct with a single
    indicator, whose residual variance ``lavaan::sem()`` fixes at zero, so the
    construct is treated as measured without error. A comment flags each
    feedback loop. A non-recursive model may not be identified (Bollen, 1989),
    and the panic-network example is not, so check identification before
    fitting. Some constructs have to be re-specified by hand. A manipulation
    is better entered as a coded observed variable and a categorical
    predictor with more than two levels as dummy variables. A covariate that
    a measurement entry only describes needs a variable of its own. Once the
    block names an observed predictor, a product term added by hand needs a
    free covariance with each observed exogenous variable that a covariance
    line names. lavaan fixes each one left unwritten at zero, and each takes
    a line of its own (``x:m ~~ x``).

    Every name is one lavaan reads as written. Construct ids that lavaan
    would refuse or misread, such as ``self-efficacy``, ``1arousal``, ``NA``
    or its own keyword ``efa``, are renamed. A comment after the header
    records each renaming
    (``# renamed for lavaan: 'self-efficacy' -> self_efficacy``). An indicator
    is its measurement entry folded and lowercased, with each run of other
    characters made one underscore. ``i_`` goes in front when the result
    would still be refused, so ``7-point Likert rating`` gives
    ``i_7_point_likert_rating``. A comment writes each control character of
    an id, and each character outside the Basic Multilingual Plane, as
    ``<U+XXXX>``, so that it stays one line that lavaan reads as a comment.
    API_SPEC.md section 19 states the rules.

    Raises:
        ValueError: when two different ids would share a name, since lavaan
            would merge them. Such a pair is two constructs, two indicators of
            one construct or a construct and an indicator. A construct and an
            indicator collide when the construct is measured by its own name
            or named like an indicator of another construct. An indicator
            shared by two constructs is allowed.

    References:
        Bollen, K. A. (1989). Structural equations with latent variables.
        Wiley. https://doi.org/10.1002/9781118619179
    """
    T = T.data if hasattr(T, "data") else T
    comments = _renamings(T)
    out = [f"# lavaan model generated by theoryforge for {_comment_text(text(T.get('id')))}",
           _WRITTEN_FOR, *comments, "# Measurement model"]
    # The indicators of each construct name, in the order of its first line.
    # lavaan reads two lines with one name, from a repeated id, as one factor.
    # The names are those of the latent variables, which the zero block needs.
    factors: dict[str, list[str]] = {}
    for c in items(T, "constructs"):
        meas = str_list(field(c, "measurement"))
        if meas:
            name = _construct_name(text(field(c, "id")))
            indicators = [_indicator_name(m) for m in meas]
            out.append(f"{name} =~ " + " + ".join(indicators))
            if name:
                own = factors.setdefault(name, [])
                for ind in indicators:
                    if ind not in own:
                        own.append(ind)
    single = [name for name, own in factors.items() if len(own) == 1]
    if single:
        out.append(_SINGLE + ", ".join(single))
    out.append("# Structural model")
    props = items(T, "propositions")
    # A moderator's main effect is written unless a path relation, wherever it
    # stands in the file, or an earlier moderation already writes that line.
    path_lines = {f"{_construct_name(text(field(p, 'to')))} ~ "
                  f"{_construct_name(text(field(p, 'from')))}"
                  for p in props if enum(field(p, "relation"), RELATION) in _PATH}
    main_effects: set[str] = set()
    for p in props:
        rel = enum(field(p, "relation"), RELATION)
        frm, to = _construct_name(text(field(p, "from"))), _construct_name(text(field(p, "to")))
        if rel in _PATH:
            out.append(f"{to} ~ {frm}")
        elif rel == "associates":
            out.append(f"{frm} ~~ {to}")
        elif rel == "moderates":
            main = f"{to} ~ {frm}"
            if main not in path_lines and main not in main_effects:
                main_effects.add(main)
                out.append(main)
            out.append(_MODERATION.format(frm=frm, to=to))
    out += _cautions(T, set(factors))
    return "\n".join(out) + "\n"

theoryforge.dossier.dossier(T)

A reviewer-facing audit bundle in Markdown (API_SPEC.md section 20).

The header gives the checklist version, the aggregate score, the checklist coverage, the gate and the blockers that failed. The rigour table prints n/a for an item with nothing to assess. The severity list gives each prediction's claim-form rubric values and, when the prediction declares a severity, that value too, with a note when it exceeds the rubric by more than 0.2. The test record lists every test outcome in file order, with what it found and the observation, date and registration it records, and with the post-data severity of post_data_severity() where it can be computed, an open bound written as -inf or inf. The evidence lists the prediction each entry supports, its direction and its DOI. The provenance and the preregistration follow, and the implied independencies appear once, in the preregistration.

Source code in src/theoryforge/dossier.py
 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
126
127
128
129
130
131
132
133
def dossier(T) -> str:
    """A reviewer-facing audit bundle in Markdown (API_SPEC.md section 20).

    The header gives the checklist version, the aggregate score, the checklist
    coverage, the gate and the blockers that failed. The rigour table prints
    ``n/a`` for an item with nothing to assess. The severity list gives each
    prediction's claim-form rubric values and, when the prediction declares a
    severity, that value too, with a note when it exceeds the rubric by more
    than 0.2. The test record lists every test outcome in file order, with what
    it found and the observation, date and registration it records, and with
    the post-data severity of ``post_data_severity()`` where it can be
    computed, an open bound written as ``-inf`` or ``inf``. The
    evidence lists the prediction each entry supports, its direction and its
    DOI. The provenance and the preregistration follow, and the implied
    independencies appear once, in the preregistration.
    """
    data = T.data if hasattr(T, "data") else T
    rep = _check(data)
    failed = [it["id"] for it in rep["items"]
              if it["severity_if_fail"] == "blocker" and it["status"] == "fail"]
    blockers = f"{rep['n_blockers_failed']} ({', '.join(failed)})" if failed else "0"
    lines = [
        f"# theoryforge dossier: {text(data.get('title'))}",
        "",
        f"- Theory ID: {text(data.get('id'))}",
        f"- Maturity: {rep['maturity']}",
        # The score is only interpretable against the checklist that produced
        # it, so a reviewer reading the bundle can see which one that was.
        f"- Checklist version: {rep['checklist_version']}",
        f"- Aggregate rigour score: {_fmt(rep['aggregate_score'])}/100",
        # The share of the checklist's weight the aggregate is the mean over.
        f"- Checklist coverage: {_fmt(rep['coverage'])}",
        f"- Gate: {rep['gate']}",
        f"- Blockers failed: {blockers}",
        "",
        "## Rigour checklist",
        "",
        "| item | status | score | weight |",
        "| --- | --- | --- | --- |",
    ]
    for it in rep["items"]:
        lines.append(f"| {it['id']} | {it['status']} | {_fmt(it['score'])} | {_fmt(it['weight'])} |")

    lines += ["", "## Severity (pre-data rubric of claim form)", ""]
    sev = _severity(data)
    if not sev:
        lines.append("_No predictions specified._")
    else:
        # check() above has refused any declared severity that is not a number
        # in [0, 1], so what is left is a number or absent.
        for p, s in zip(items(data, "predictions"), sev, strict=True):
            pid, cs, rk = s["prediction_id"], _fmt(s["computed_severity"]), _fmt(s["risk_score"])
            line = f"- {pid}: severity {cs}, risk {rk}"
            declared = field(p, "severity")
            if declared is not None:
                line += f", declared {_fmt(declared)}"
                if rnd(declared - s["computed_severity"], 3) > DECLARED_MARGIN:
                    line += f" (declared exceeds the rubric by more than {_fmt(DECLARED_MARGIN)})"
            lines.append(line)

    # Every outcome, in file order, with the fields it records (API_SPEC.md
    # section 20). check() above has refused a passed that is not a boolean.
    # An outcome whose post-data severity can be computed gives it last, and
    # one that post_data_severity() would refuse is listed without it.
    lines += ["", "## Test record", ""]
    tos = items(data, "test_outcomes")
    if not tos:
        lines.append("_No test outcomes recorded._")
    else:
        for t, (row, _) in zip(tos, _outcome_rows(data), strict=True):
            line = f"- {text(field(t, 'prediction_id'))}: {_outcome_result(t)}"
            for key in ("observed", "date", "registered"):
                value = text(field(t, key))
                if ne_str(value):
                    line += f"; {key}: {value}"
            if row is not None:
                line += _post_data(row)
            lines.append(line)

    lines += ["", "## Evidence", ""]
    evidence = items(data, "evidence")
    if not evidence:
        lines.append("_No evidence recorded._")
    else:
        for e in evidence:
            direction = enum(field(e, "direction"), EVIDENCE_DIRECTION) or ""
            line = f"- supports {text(field(e, 'supports'))}: {direction}"
            doi = text(field(e, "source_doi"))
            if ne_str(doi):
                line += f"; doi: {doi}"
            lines.append(line)

    lines += ["", "## Provenance", ""]
    prov = items(data, "provenance")
    if not prov:
        lines.append("_No provenance recorded._")
    else:
        for i, s in enumerate(prov, start=1):
            action = text(field(s, "action"))
            detail = text(field(s, "detail"))
            lines.append(f"{i}. {action}: {detail}" if trim(detail) else f"{i}. {action}")

    lines += ["", "## Preregistration", ""]
    return "\n".join(lines) + "\n" + _preregister(data)

Simulation

Integrate the construct network as a deterministic dynamical system.

theoryforge.simulate.simulate(T, steps=10, dt=0.1, k=1.0, damping=0.5, init=1.0, method='euler')

Propagate the theory's construct network as a linear dynamical system.

Returns {states, dt, steps, k, damping, init, method, ignored, opposed, trajectory}, where trajectory[t] is the state vector at time t*dt (t = 0..steps), each value rounded to 6 decimals. The knobs and the method are echoed back as given, because the trajectory cannot be reproduced without them. ignored lists the ids of the propositions that couple nothing (moderates, associates, a relation outside the schema's set, or an endpoint that is not a declared construct), and opposed the [from, to] pairs that carry couplings of both signs, which offset each other. Construct ids must be unique. A construct without an id is a state with no couplings, and two of them do not count as duplicates.

method = "exact" multiplies the state by the matrix exponential of (A - dampingI)dt at every step, so the trajectory is the solution of the linear system at those times whatever dt is. method = "euler" (the default for this release) takes fixed explicit steps, which are stable only when |1 + dtlambda| < 1 for every eigenvalue lambda of A - dampingI. A theory that decays at rate damping therefore explodes with alternating sign once dt*damping exceeds 2, and the steps inflate a sustained oscillation into growth. The Euler run warns (UserWarning) when it departs from the exact solution by more than 5 per cent of max(1, |exact|) at any step. The default will change to "exact" in the next minor release.

steps must be a whole number of at least 0, dt a finite number above 0, k, damping and init finite numbers, and method one of "euler" and "exact". Anything else raises ValueError. When a state grows so large that a million times it is not a finite double, the run stops with ValueError naming the step and the state. The initial state is tested as step 0, so an init beyond about 1.8e302 in absolute value stops there.

The model is deliberately simple: one gain k for every coupling, causes and mediates taken as positive, moderates and associates coupling nothing, functional_form not read and a common initial value for every state. A linear system has one equilibrium, or a continuum of them when its matrix is singular, and never two separate ones, so it cannot show bistability.

Source code in src/theoryforge/simulate.py
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
def simulate(T, steps: int = 10, dt: float = 0.1, k: float = 1.0,
             damping: float = 0.5, init: float = 1.0, method: str = "euler") -> dict:
    """Propagate the theory's construct network as a linear dynamical system.

    Returns {states, dt, steps, k, damping, init, method, ignored, opposed,
    trajectory}, where trajectory[t] is the state vector at time t*dt (t = 0..steps),
    each value rounded to 6 decimals. The knobs and the method are echoed back as
    given, because the trajectory cannot be reproduced without them. ``ignored``
    lists the ids of the propositions that couple nothing (moderates, associates, a
    relation outside the schema's set, or an endpoint that is not a declared
    construct), and ``opposed`` the ``[from, to]`` pairs that carry couplings of
    both signs, which offset each other. Construct ids must be unique. A construct
    without an id is a state with no couplings, and two of them do not count as
    duplicates.

    ``method = "exact"`` multiplies the state by the matrix exponential of
    (A - damping*I)*dt at every step, so the trajectory is the solution of the
    linear system at those times whatever ``dt`` is. ``method = "euler"`` (the
    default for this release) takes fixed explicit steps, which are stable only
    when |1 + dt*lambda| < 1 for every eigenvalue lambda of A - damping*I. A theory
    that decays at rate ``damping`` therefore explodes with alternating sign once
    dt*damping exceeds 2, and the steps inflate a sustained oscillation into
    growth. The Euler run warns (UserWarning) when it departs from the exact
    solution by more than 5 per cent of max(1, |exact|) at any step. The default
    will change to ``"exact"`` in the next minor release.

    ``steps`` must be a whole number of at least 0, ``dt`` a finite number above 0,
    ``k``, ``damping`` and ``init`` finite numbers, and ``method`` one of "euler"
    and "exact". Anything else raises ValueError. When a state grows so large that
    a million times it is not a finite double, the run stops with ValueError naming
    the step and the state. The initial state is tested as step 0, so an ``init``
    beyond about 1.8e302 in absolute value stops there.

    The model is deliberately simple: one gain ``k`` for every coupling, ``causes``
    and ``mediates`` taken as positive, ``moderates`` and ``associates`` coupling
    nothing, ``functional_form`` not read and a common initial value for every
    state. A linear system has one equilibrium, or a continuum of them when its
    matrix is singular, and never two separate ones, so it cannot show bistability.
    """
    n_steps = _check_knobs(steps, dt, k, damping, init, method)
    T = T.data if hasattr(T, "data") else T
    states = [text(field(c, "id")) for c in items(T, "constructs")]
    n = len(states)
    # Duplicate construct ids have no defensible reading here, and the two
    # engines resolved them differently by accident (a dict comprehension keeps
    # the last index, R's `[[` on a named vector the first), so the same file
    # produced two plausible trajectories. Refuse instead of picking a winner.
    # Constructs without ids share no id: no proposition can name them, so
    # each is a state with no couplings.
    seen: set = set()
    for s in states:
        if s in seen and s != "":
            raise ValueError(f"simulate requires unique construct ids; duplicate construct id: {s}")
        seen.add(s)
    idx = {s: i for i, s in enumerate(states) if s != ""}

    A = [[0.0] * n for _ in range(n)]
    ignored: list = []
    # The signs each ordered pair carries, in the order the pairs first couple.
    pair_signs: dict = {}
    for p in items(T, "propositions"):
        f, t = text(field(p, "from")), text(field(p, "to"))
        rel = enum(field(p, "relation"), RELATION)
        sign = 1.0 if rel in _POS else (-1.0 if rel in _NEG else 0.0)
        if f in idx and t in idx:
            A[idx[t]][idx[f]] += sign * k
        if f in idx and t in idx and sign != 0.0:
            pair_signs.setdefault((f, t), set()).add(sign)
        else:
            ignored.append(text(field(p, "id")))
    opposed = [[f, t] for (f, t), signs in pair_signs.items() if len(signs) == 2]

    # The exact propagator, which method "exact" steps with and the Euler run
    # is checked against. Neither needs it when nothing moves.
    E: list = []
    if n > 0 and n_steps > 0:
        J = [[A[i][j] - (damping if i == j else 0.0) for j in range(n)] for i in range(n)]
        E = _propagator(J, dt)
    # A smaller dt cures an Euler divergence that the step causes, but it does
    # not change the exact solution, which grows only when the system does.
    # Raising damping shifts every eigenvalue of J to the left, so it tames
    # the exact solution, while it can make the Euler steps diverge.
    remedy = ("raise damping, reduce k or take fewer steps" if method == "exact"
              else "reduce dt or k")

    X = [float(init)] * n
    # Row 0 is tested as every update is. A finite init can be too large to
    # round (about 1.8e302 or more), and only init shapes this row.
    _check_finite(X, states, 0, "reduce init")
    Y = list(X)  # the exact state the Euler run is compared with
    departed = False
    traj = [[rnd(x, 6) for x in X]]
    for step in range(1, n_steps + 1):
        if method == "exact":
            X = _apply(E, X)
        else:
            dX = []
            for i in range(n):
                # A left-to-right loop, as R adds. The builtin sum() compensates
                # rounding error from Python 3.12, and in fast-growing regimes that
                # moved the twins apart beyond the parity tolerance.
                acc = 0.0
                for j in range(n):
                    acc = acc + A[i][j] * X[j]
                dX.append(acc - damping * X[i])
            X = [X[i] + dt * dX[i] for i in range(n)]
        _check_finite(X, states, step, remedy)
        if method == "euler" and not departed:
            Y = _apply(E, Y)
            for i in range(n):
                # An exact state beyond the double range is a departure too,
                # since the Euler state is still finite here.
                if (not math.isfinite(Y[i])
                        or abs(X[i] - Y[i]) > _DEPARTURE * max(1.0, abs(Y[i]))):
                    departed = True
                    break
        traj.append([rnd(x, 6) for x in X])

    if departed:
        warnings.warn(_EULER_WARNING, UserWarning, stacklevel=2)
    return {"states": states, "dt": dt, "steps": steps, "k": k, "damping": damping,
            "init": init, "method": method, "ignored": ignored, "opposed": opposed,
            "trajectory": traj}

Reporting and deposit

Render a report from a theory, and deposit the result.

theoryforge.render_report(T, path, title=None, render=False, to='html')

Write a Quarto report for the theory; render it with Quarto when render=True.

title defaults to "theoryforge report: <title-or-id>", also when empty. It is written as a YAML double-quoted scalar with backslashes, double quotes and control characters escaped, so a YAML parser reads back exactly the string passed. Quarto then reads the title as Markdown, so raw TeX such as \emph{} is dropped from HTML output and $\alpha$ becomes mathematics.

Returns the path of the written .qmd.

Source code in src/theoryforge/report_render.py
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
def render_report(T, path, title: str | None = None, render: bool = False, to: str = "html") -> str:
    """Write a Quarto report for the theory; render it with Quarto when ``render=True``.

    ``title`` defaults to ``"theoryforge report: <title-or-id>"``, also when empty. It is
    written as a YAML double-quoted scalar with backslashes, double quotes and control
    characters escaped, so a YAML parser reads back exactly the string passed. Quarto then
    reads the title as Markdown, so raw TeX such as ``\\emph{}`` is dropped from HTML output
    and ``$\\alpha$`` becomes mathematics.

    Returns the path of the written `.qmd`.
    """
    data = T.data if hasattr(T, "data") else T
    # Fall back to the id when the title is empty as well as absent (matches R's nzchar fallback).
    title = title or f"theoryforge report: {text(data.get('title')) or text(data.get('id'))}"
    path = Path(path)
    if path.suffix.lower() != ".qmd":
        path = path.with_suffix(".qmd")
    header = f'---\ntitle: "{_yaml_dq(title)}"\nformat: {to}\n---\n\n'
    _write_lf(path, header + _dossier(data))
    if render:
        subprocess.run(["quarto", "render", str(path), "--to", to], check=True)  # pragma: no cover
    return str(path)

theoryforge.osf_push(T, token=None, node=None, filename=None, dry_run=True, base_url=_DEFAULT_BASE, overwrite=False)

Deposit the theory's dossier on OSF.

With dry_run=True (default) the planned request is returned and nothing is sent. A live upload (dry_run=False) requires both token and node (the OSF project id). An empty node or filename counts as absent.

OSF storage refuses to create a file whose name already exists in the project folder (HTTP 409), so depositing the same theory twice under the default filename fails. Pass a version-specific filename, or overwrite=True to add a new version of the existing file: the folder is listed first and the dossier is sent to that file's upload link, the WaterButler route that records a new OSF version. When the folder holds no file of that name, overwrite=True creates it as usual. The dry run then also returns the lookup request.

Raises :class:OSFUploadError, a subclass of urllib.error.HTTPError, when OSF answers with a status outside 2xx, so a refused upload is never returned as a completed one.

Source code in src/theoryforge/osf.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
181
182
183
184
185
186
187
188
189
190
def osf_push(T, token: str | None = None, node: str | None = None,
             filename: str | None = None, dry_run: bool = True,
             base_url: str = _DEFAULT_BASE, overwrite: bool = False) -> dict:
    """Deposit the theory's dossier on OSF.

    With ``dry_run=True`` (default) the planned request is returned and nothing is sent. A live
    upload (``dry_run=False``) requires both ``token`` and ``node`` (the OSF project id). An
    empty ``node`` or ``filename`` counts as absent.

    OSF storage refuses to create a file whose name already exists in the project folder
    (HTTP 409), so depositing the same theory twice under the default filename fails. Pass a
    version-specific ``filename``, or ``overwrite=True`` to add a new version of the existing
    file: the folder is listed first and the dossier is sent to that file's upload link, the
    WaterButler route that records a new OSF version. When the folder holds no file of that
    name, ``overwrite=True`` creates it as usual. The dry run then also returns the
    ``lookup`` request.

    Raises :class:`OSFUploadError`, a subclass of ``urllib.error.HTTPError``, when OSF answers
    with a status outside 2xx, so a refused upload is never returned as a completed one.
    """
    if not isinstance(overwrite, bool):
        raise ValueError("overwrite must be True or False")
    data = T.data if hasattr(T, "data") else T
    # A null or empty id must not leak into the filename ('None.dossier.md' /
    # '.dossier.md'); fall back to 'theory' as the R twin's nzchar guard does.
    tid = text(data.get("id")) or "theory"
    fname = filename or f"{tid}.dossier.md"
    content = _dossier(data)
    # Percent-encode the filename (theory ids are user-supplied, so fname may
    # carry spaces, '&' or '#'); mirrors the R utils::URLencode(reserved = TRUE,
    # repeated = TRUE) call so the dry-run request dicts stay parity-identical.
    url = (
        f"{base_url}{node}/providers/osfstorage/?kind=file&name={_quote(fname, safe='')}"
        if node else None
    )
    request = {"method": "PUT", "url": url, "filename": fname,
               "content_bytes": len(content.encode("utf-8"))}
    lookup_url = f"{base_url}{node}/providers/osfstorage/" if node else None

    if dry_run:
        out: dict = {"dry_run": True, "request": request}
        note = "set dry_run=False with a valid token and node to perform the upload"
        if overwrite:
            out["lookup"] = {"method": "GET", "url": lookup_url}
            note += ("; with overwrite, the lookup lists the folder first and the PUT goes "
                     "to the upload link of an existing file of that name")
        out["note"] = note
        return out
    if not token or not node:
        raise ValueError("a live OSF push requires both `token` and `node` (the OSF project id)")
    assert url is not None and lookup_url is not None  # node is set, so both were built above

    put_url = url
    if overwrite:
        _, listing = _send("GET", lookup_url, token, None, "OSF folder listing")
        link = _upload_link(listing, fname)
        if link is not None:
            put_url = _update_url(link)
    status, _ = _send("PUT", put_url, token, content.encode("utf-8"), "OSF upload")
    return {"dry_run": False, "status": status, "filename": fname}

theoryforge.OSFUploadError

Bases: HTTPError

OSF storage refused a request (API_SPEC.md section 25).

A subclass of urllib.error.HTTPError, so a handler written for the error that urlopen raises still catches it, with the same code, headers and readable body. Its str() is the message the R twin stops with: OSF upload failed with HTTP <code>, with a hint on adding a version for a 409, or OSF folder listing failed with HTTP <code> for the lookup that overwrite=True makes first.

Source code in src/theoryforge/osf.py
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
class OSFUploadError(urllib.error.HTTPError):
    """OSF storage refused a request (API_SPEC.md section 25).

    A subclass of ``urllib.error.HTTPError``, so a handler written for the error
    that ``urlopen`` raises still catches it, with the same ``code``, headers and
    readable body. Its ``str()`` is the message the R twin stops with:
    ``OSF upload failed with HTTP <code>``, with a hint on adding a version for a
    409, or ``OSF folder listing failed with HTTP <code>`` for the lookup that
    ``overwrite=True`` makes first.
    """

    def __init__(self, url: str, code: int, message: str, hdrs, body: bytes):
        super().__init__(url, code, message, hdrs, io.BytesIO(body))

    def __str__(self) -> str:
        return self.msg

    def __repr__(self) -> str:
        return f"<{type(self).__name__} {self.code}: {self.msg!r}>"

Literature layer

Map the literature a theory sits in, and check what of it the theory does not yet cite.

theoryforge.read_corpus(path)

Read a literature corpus from YAML or JSON (JSON when the suffix is .json).

The file is read by the rules read follows, which are the R twin's (API_SPEC.md section 3), so an unquoted keyword such as y or n stays a string and a repeated key is refused.

Source code in src/theoryforge/lit.py
41
42
43
44
45
46
47
48
49
50
51
def read_corpus(path) -> dict:
    """Read a literature corpus from YAML or JSON (JSON when the suffix is ``.json``).

    The file is read by the rules ``read`` follows, which are the R twin's
    (API_SPEC.md section 3), so an unquoted keyword such as ``y`` or ``n`` stays a
    string and a repeated key is refused.
    """
    data = load_document(path)
    if not isinstance(data, dict):
        raise ValueError("Corpus data must be a mapping")
    return data

theoryforge.litmap(corpus, min_link=DEFAULT_MIN_LINK, method='components', min_cocitation=None, min_theme_size=2, max_theme_size=10, max_df=1.0)

Keyword co-occurrence, themes and co-citation, all deterministic.

min_link is the smallest count a keyword pair needs to be an edge, and min_cocitation the same for a reference pair (min_link when None). Co-citation maps of real corpora are large: 200 OpenAlex records give about 11,000 reference pairs at a threshold of 2, so a higher min_cocitation is often wanted. Both must be positive integers. The corpus is checked as API_SPEC.md section 14 describes: integer entries become decimal strings, and booleans, fractions and nested values are refused.

A reference that holds a DOI is keyed by the DOI, lower-cased and without its resolver prefix, as new_evidence_dois compares DOIs. One work cited as 10.1016/J.BRAT.2015.10.002 in one record and as https://doi.org/10.1016/j.brat.2015.10.002 in another is therefore one node, labelled 10.1016/j.brat.2015.10.002. Any other reference, such as a Scopus or OpenAlex identifier, is compared exactly as written, and so is every keyword.

method chooses how keywords are grouped into themes. With "components", the default, a theme is a connected component of the keyword map, and on a real corpus a few keywords shared by most records join nearly every keyword into one. When the largest theme holds more than half the linked keywords, a UserWarning says so: such themes, and any landscape built on them, do not describe the field. The result is returned unchanged.

"simple_centres" is the co-word clustering of Coulter et al. (1998) and Cobo et al. (2011). Each link is weighted by the equivalence index c * c / (df_a * df_b), where c counts the records holding both keywords and df the records holding each. The strongest link between two unassigned keywords seeds a theme, which takes its strongest unassigned neighbour until it holds max_theme_size keywords, ties going to the keyword first in code-point order. A keyword whose links all reach keywords already in themes joins none. Themes smaller than min_theme_size are dropped. Keywords in more than max_df of the records are field terms, left out of the map and reported as field_terms. Each theme gains its centrality (ten times the summed index of its links to the keywords of other themes) and its density (100 times the summed index of its internal links, over its size), the measures of Callon et al. (1991) as Cobo et al. (2011) scale them. It also gains its quadrant in the strategic diagram, split here at the median of each: motor (both high), basic (central but not dense), niche (dense but not central) or emerging_or_declining (both low). The record gains method, parameters (the five settings used) and field_terms. Components remain the default for this release.

min_theme_size must be a positive integer, max_theme_size an integer of at least 2 no smaller than it, and max_df a number from 0 to 1. They are checked with either method, although only simple centres reads them.

References: Callon, Courtial and Laville (1991), Scientometrics 22, 155-205, https://doi.org/10.1007/BF02019280. Coulter, Monarch and Konda (1998), Journal of the American Society for Information Science 49, 1206-1223. Cobo, López-Herrera, Herrera-Viedma and Herrera (2011), Journal of Informetrics 5, 146-166, https://doi.org/10.1016/j.joi.2010.10.002.

Source code in src/theoryforge/lit.py
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
455
def litmap(corpus, min_link: int = DEFAULT_MIN_LINK, method: str = "components",
           min_cocitation: int | None = None, min_theme_size: int = 2,
           max_theme_size: int = 10, max_df: float = 1.0) -> dict:
    """Keyword co-occurrence, themes and co-citation, all deterministic.

    ``min_link`` is the smallest count a keyword pair needs to be an edge, and
    ``min_cocitation`` the same for a reference pair (``min_link`` when None).
    Co-citation maps of real corpora are large: 200 OpenAlex records give about
    11,000 reference pairs at a threshold of 2, so a higher ``min_cocitation``
    is often wanted. Both must be positive integers. The corpus is checked as
    API_SPEC.md section 14 describes: integer entries become decimal strings,
    and booleans, fractions and nested values are refused.

    A reference that holds a DOI is keyed by the DOI, lower-cased and without
    its resolver prefix, as ``new_evidence_dois`` compares DOIs. One work cited
    as ``10.1016/J.BRAT.2015.10.002`` in one record and as
    ``https://doi.org/10.1016/j.brat.2015.10.002`` in another is therefore one
    node, labelled ``10.1016/j.brat.2015.10.002``. Any other reference, such as
    a Scopus or OpenAlex identifier, is compared exactly as written, and so is
    every keyword.

    ``method`` chooses how keywords are grouped into themes. With
    ``"components"``, the default, a theme is a connected component of the
    keyword map, and on a real corpus a few keywords shared by most records
    join nearly every keyword into one. When the largest theme holds more than
    half the linked keywords, a UserWarning says so: such themes, and any
    landscape built on them, do not describe the field. The result is returned
    unchanged.

    ``"simple_centres"`` is the co-word clustering of Coulter et al. (1998) and
    Cobo et al. (2011). Each link is weighted by the equivalence index
    ``c * c / (df_a * df_b)``, where ``c`` counts the records holding both
    keywords and ``df`` the records holding each. The strongest link between
    two unassigned keywords seeds a theme, which takes its strongest unassigned
    neighbour until it holds ``max_theme_size`` keywords, ties going to the
    keyword first in code-point order. A keyword whose links all reach
    keywords already in themes joins none. Themes smaller than
    ``min_theme_size`` are dropped. Keywords in more than ``max_df`` of the
    records are field terms, left out of the map and reported as
    ``field_terms``. Each theme gains its centrality (ten times the summed
    index of its links to the keywords of other themes) and its density (100
    times the summed index of its internal links, over its size), the measures
    of Callon et al. (1991) as Cobo et al. (2011) scale them. It also gains its quadrant in the
    strategic diagram, split here at the median of each: ``motor`` (both high),
    ``basic`` (central but not dense), ``niche`` (dense but not central) or
    ``emerging_or_declining`` (both low). The record gains ``method``,
    ``parameters`` (the five settings used) and ``field_terms``. Components
    remain the default for this release.

    ``min_theme_size`` must be a positive integer, ``max_theme_size`` an
    integer of at least 2 no smaller than it, and ``max_df`` a number from 0 to
    1. They are checked with either method, although only simple centres reads
    them.

    References: Callon, Courtial and Laville (1991), Scientometrics 22,
    155-205, https://doi.org/10.1007/BF02019280. Coulter, Monarch and Konda
    (1998), Journal of the American Society for Information Science 49,
    1206-1223. Cobo, López-Herrera, Herrera-Viedma and Herrera (2011), Journal
    of Informetrics 5, 146-166, https://doi.org/10.1016/j.joi.2010.10.002.
    """
    s = _settings(min_link, method, min_cocitation, min_theme_size, max_theme_size, max_df)
    out = _litmap(corpus, s)
    if s["method"] == "components":
        _warn_if_one_theme_dominates(out["themes"])
    return out

theoryforge.landscape(theory, corpus, min_link=DEFAULT_MIN_LINK, max_token_share=DEFAULT_MAX_TOKEN_SHARE, method='components')

Map a theory and its registered alternatives onto the literature's themes.

A theme is matched by the words its keywords share with the focal theory's construct labels, or with an alternative's label and key constructs, and every match reports those words (focal_terms, alternative_terms). Three kinds of word never match. Field tokens, the words most of the corpus shares, are those in the keywords of more than max_token_share of the records. Phenomenon tokens are the words of the theory's title, which names the phenomenon that the focal theory and its rivals all explain. A construct word that also appears in the title does not match either. The third kind is the words that name a kind of account, held in theoryforge.lit.THEORY_WORDS: theory, model, account, hypothesis, framework and approach, with their plurals. One shared word is enough for a match.

A theme is under_theorised when none of the registered accounts (the focal theory and its registered alternatives) addresses it, covered when one does and crowded when two or more do. A crowded theme calls for predictions that discriminate between the accounts, and is not a finding of redundancy. The two lists keep their 0.6.0 keys, under_theorised_fronts and redundancy_risk.

min_link must be a positive integer, max_token_share a number from 0 to 1 and method one of litmap's, checked in that order and before the corpus (API_SPEC.md section 15). The themes are litmap's, built by method with litmap's other defaults. With "components", the same warning is given when one theme holds most of the linked keywords. With "simple_centres", the record gains method after theory_id and each theme its centrality, density and quadrant.

Source code in src/theoryforge/lit.py
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
def landscape(theory, corpus, min_link: int = DEFAULT_MIN_LINK,
              max_token_share: float = DEFAULT_MAX_TOKEN_SHARE,
              method: str = "components") -> dict:
    """Map a theory and its registered alternatives onto the literature's themes.

    A theme is matched by the words its keywords share with the focal theory's
    construct labels, or with an alternative's label and key constructs, and
    every match reports those words (``focal_terms``, ``alternative_terms``).
    Three kinds of word never match. Field tokens, the words most of the corpus
    shares, are those in the keywords of more than ``max_token_share`` of the
    records. Phenomenon tokens are the words of the theory's title, which names
    the phenomenon that the focal theory and its rivals all explain. A
    construct word that also appears in the title does not match either. The
    third kind is the words that name a kind of account, held in
    ``theoryforge.lit.THEORY_WORDS``: theory, model, account, hypothesis,
    framework and approach, with their plurals. One shared word is enough for
    a match.

    A theme is ``under_theorised`` when none of the registered accounts (the
    focal theory and its registered alternatives) addresses it, ``covered``
    when one does and ``crowded`` when two or more do. A crowded theme calls for
    predictions that discriminate between the accounts, and is not a finding of
    redundancy. The two lists keep their 0.6.0 keys, ``under_theorised_fronts``
    and ``redundancy_risk``.

    ``min_link`` must be a positive integer, ``max_token_share`` a number
    from 0 to 1 and ``method`` one of litmap's, checked in that order and
    before the corpus (API_SPEC.md section 15). The themes are litmap's, built
    by ``method`` with litmap's other defaults. With ``"components"``, the same
    warning is given when one theme holds most of the linked keywords. With
    ``"simple_centres"``, the record gains ``method`` after ``theory_id`` and
    each theme its ``centrality``, ``density`` and ``quadrant``.
    """
    T = theory.data if hasattr(theory, "data") else theory
    min_link = _positive_int(min_link, "min_link")
    max_token_share = _share(max_token_share, "max_token_share")
    s = _settings(min_link, method, None, 2, 10, 1.0)
    records = _records(corpus)
    lm = _litmap(corpus, s, co_citation=False, records=records)
    if s["method"] == "components":
        _warn_if_one_theme_dominates(lm["themes"])

    field_tokens = _field_tokens([kw for kw, _ in records], max_token_share)
    phenomenon_tokens = tokens(text(T.get("title")))
    excluded = phenomenon_tokens | THEORY_WORDS
    focal_tokens = tokens(" ".join(text(field(c, "label")) for c in items(T, "constructs"))) - excluded
    # Sorted by id once, stably, so a repeated id keeps its file order.
    alts = sorted(
        ((text(field(a, "id")),
          tokens(" ".join([text(field(a, "label"))] + str_list(field(a, "key_constructs")))) - excluded)
         for a in items(T, "alternatives")),
        key=lambda alt: alt[0],
    )

    themes_out = []
    under, crowded = [], []
    for th in lm["themes"]:
        th_tokens = tokens(" ".join(th["keywords"])) - field_tokens - THEORY_WORDS
        focal_terms = sorted(focal_tokens & th_tokens)
        alt_terms = [{"id": aid, "terms": sorted(a_tokens & th_tokens)}
                     for aid, a_tokens in alts if a_tokens & th_tokens]
        on = [a["id"] for a in alt_terms]
        focal_on = bool(focal_terms)
        n = len(on) + (1 if focal_on else 0)
        status = "under_theorised" if n == 0 else ("crowded" if n >= 2 else "covered")
        theme = {
            "id": th["id"], "keywords": th["keywords"],
            "alternatives": on, "focal": focal_on, "status": status,
            "focal_terms": focal_terms, "alternative_terms": alt_terms,
        }
        if s["method"] == "simple_centres":
            theme.update({k: th[k] for k in ("centrality", "density", "quadrant")})
        themes_out.append(theme)
        if status == "under_theorised":
            under.append(th["id"])
        elif status == "crowded":
            crowded.append(th["id"])

    head = {"theory_id": text(T.get("id"))}
    if s["method"] == "simple_centres":
        head["method"] = s["method"]
    return {
        **head,
        "max_token_share": max_token_share,
        "field_tokens": sorted(field_tokens),
        "phenomenon_tokens": sorted(phenomenon_tokens),
        "themes": themes_out,
        "under_theorised_fronts": under,
        "redundancy_risk": crowded,
    }

theoryforge.fetch_corpus(query, per_page=25, mailto=None, api_key=None, max_records=None)

Build a corpus from the OpenAlex API (network call).

This adapter is assistive. It depends on a live external service whose results change over time, so it sits outside the package's deterministic core. Each work is mapped to {id, doi, title, year, keywords, references}, with the DOI as OpenAlex gives it and the top five concepts standing in for keywords when a work has none.

OpenAlex returns the works that match a search in pages of per_page, ranked by relevance, and a search usually matches far more works than one page holds. per_page may be 1 to 200, but OpenAlex supports pages of up to 100 and has deprecated larger ones. A max_records above per_page pages on through OpenAlex's cursor until that many works are collected or the results run out. By default it equals per_page, so one request is made. Each page is one request and costs USD 0.001. OpenAlex allows USD 0.10 a day without a key, about 100 pages, and USD 1 with a free key.

api_key defaults to the OPENALEX_API_KEY environment variable, and "" sends no key. The key is sent only in an Authorization: Bearer header, never in the URL, the corpus or an error message. mailto is still sent, but OpenAlex now ignores it, since API keys replaced the polite pool it once selected.

When OpenAlex refuses a request, as it does with HTTP 429 once the budget is spent, OpenAlexHTTPError (a urllib.error.HTTPError) is raised with the status and OpenAlex's own message, and the request is not retried. A response without a results list raises ValueError.

The corpus records where, when and how it was fetched in source. It gives the service, endpoint and query and the UTC time of retrieval (retrieved), then the number of works that matched (total_count), the number kept (n_records), the page size and the order (sort). The date matters because the keywords change. Since late September 2026, OpenAlex has written each work's keywords with a language model that reads its title, abstract and venue. It merges and splits that vocabulary over time. Works without keywords fall back to their concepts, a deprecated vocabulary with capitalised names that do not match the lower-case keywords in litmap. Save a fetched corpus and work from the saved file.

Source code in src/theoryforge/lit.py
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
def fetch_corpus(query: str, per_page: int = 25, mailto: str | None = None,
                 api_key: str | None = None, max_records: int | None = None) -> dict:
    """Build a corpus from the OpenAlex API (network call).

    This adapter is assistive. It depends on a live external service whose
    results change over time, so it sits outside the package's deterministic
    core. Each work is mapped to ``{id, doi, title, year, keywords, references}``,
    with the DOI as OpenAlex gives it and the top five concepts standing in for
    keywords when a work has none.

    OpenAlex returns the works that match a search in pages of ``per_page``,
    ranked by relevance, and a search usually matches far more works than one
    page holds. ``per_page`` may be 1 to 200, but OpenAlex supports pages of up
    to 100 and has deprecated larger ones. A ``max_records`` above ``per_page``
    pages on through OpenAlex's cursor until that many works are collected or
    the results run out. By default it equals ``per_page``, so one request is
    made. Each page is one request and costs USD 0.001. OpenAlex allows USD 0.10
    a day without a key, about 100 pages, and USD 1 with a free key.

    ``api_key`` defaults to the ``OPENALEX_API_KEY`` environment variable, and
    ``""`` sends no key. The key is sent only in an ``Authorization: Bearer``
    header, never in the URL, the corpus or an error message. ``mailto`` is
    still sent, but OpenAlex now ignores it, since API keys replaced the polite
    pool it once selected.

    When OpenAlex refuses a request, as it does with HTTP 429 once the budget is
    spent, ``OpenAlexHTTPError`` (a ``urllib.error.HTTPError``) is raised with
    the status and OpenAlex's own message, and the request is not retried. A
    response without a ``results`` list raises ValueError.

    The corpus records where, when and how it was fetched in ``source``. It
    gives the service, endpoint and query and the UTC time of retrieval
    (``retrieved``), then the number of works that matched (``total_count``),
    the number kept (``n_records``), the page size and the order (``sort``).
    The date matters because the keywords change. Since late September 2026,
    OpenAlex has written each work's keywords with a language model that reads
    its title, abstract and venue. It merges and splits that vocabulary over
    time. Works without keywords fall back to their concepts, a deprecated
    vocabulary with capitalised names that do not match the lower-case keywords
    in ``litmap``. Save a fetched corpus and work from the saved file.
    """
    # Reject out-of-range page sizes here rather than passing them through for
    # OpenAlex to reject, and with the same message the R twin uses.
    if isinstance(per_page, bool) or not isinstance(per_page, int) or not 1 <= per_page <= 200:
        raise ValueError("per_page must be between 1 and 200")
    max_records = per_page if max_records is None else _positive_int(max_records, "max_records")
    key = _api_key(os.environ.get("OPENALEX_API_KEY", "") if api_key is None else api_key)
    headers = {"Authorization": f"Bearer {key}"} if key else {}
    params = {"search": query, "per-page": str(per_page)}
    if mailto:
        params["mailto"] = mailto

    # Cursor paging, which OpenAlex serves for a search with no cap on the
    # number of results: "*" asks for the first page, and each page names the
    # cursor of the next. The run ends with enough works, an empty page or no
    # cursor (API_SPEC.md section 17).
    retrieved = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
    page = _openalex_page({**params, "cursor": "*"}, headers, key)
    total_count = _whole_number(_meta(page).get("count"))
    records = [_openalex_record(w) for w in page["results"]]
    cursor = _meta(page).get("next_cursor")
    while len(records) < max_records and page["results"] and ne_str(cursor):
        page = _openalex_page({**params, "cursor": cursor}, headers, key)
        records += [_openalex_record(w) for w in page["results"]]
        cursor = _meta(page).get("next_cursor")
    records = records[:max_records]

    return {
        "schema_version": "1.0",
        "id": f"openalex:{query}",
        "source": {
            "service": "OpenAlex",
            "endpoint": _OPENALEX_WORKS,
            "query": query,
            "retrieved": retrieved,
            "total_count": total_count,
            "n_records": len(records),
            "per_page": per_page,
            # The order OpenAlex gives a search unless told otherwise. The
            # adapter sends no sort, so this records that default.
            "sort": "relevance_score:desc",
        },
        "records": records,
    }

theoryforge.new_evidence_dois(theory, candidate_dois)

DOIs in candidate_dois not already cited by the theory's evidence or alternatives.

Compares by normalised form, so a fresh literature search, for example via OpenAlex, Scopus, or any other source, can be checked against what the theory already engages with. The normalised form is the DOI itself, trimmed, lowercased (ASCII letters only) and percent-decoded, wherever it sits in the text, so doi: 10..., DOI 10..., doi.org/10..., https://www.doi.org/10... and a URL with %2F all match the bare DOI, and trailing full stops, commas and semicolons are dropped. Returns the qualifying DOIs in their original form, deduplicated and sorted by normalised form. Deterministic and takes no network dependency: the search itself is left to whichever literature tool the caller prefers.

Source code in src/theoryforge/lit.py
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
def new_evidence_dois(theory, candidate_dois: list) -> list:
    """DOIs in `candidate_dois` not already cited by the theory's evidence or alternatives.

    Compares by normalised form, so a fresh literature search, for example via OpenAlex,
    Scopus, or any other source, can be checked against what the theory already engages
    with. The normalised form is the DOI itself, trimmed, lowercased (ASCII letters only)
    and percent-decoded, wherever it sits in the text, so `doi: 10...`, `DOI 10...`,
    `doi.org/10...`, `https://www.doi.org/10...` and a URL with `%2F` all match the bare
    DOI, and trailing full stops, commas and semicolons are dropped. Returns the
    qualifying DOIs in their original form, deduplicated and sorted by normalised form.
    Deterministic and takes no network dependency: the search itself is left to
    whichever literature tool the caller prefers.
    """
    T = theory.data if hasattr(theory, "data") else theory
    known = set()
    for key in ("evidence", "alternatives"):
        for entry in items(T, key):
            doi = text(field(entry, "source_doi"))
            if doi:
                known.add(normalise_doi(doi))

    seen, out = set(), []
    for doi in candidate_dois or []:
        if not doi:
            continue
        norm = normalise_doi(doi)
        if norm in known or norm in seen:
            continue
        seen.add(norm)
        out.append(doi)
    return sorted(out, key=normalise_doi)