Skip to contents

theoryforge treats a scientific theory as a versioned, machine-checkable object. This vignette walks through the core loop of building a theory, checking its rigour and diagramming it (see the package overview for the R/Python parity guarantee).

Building a theory

A theory is built incrementally with the BUILDING-mode verbs. Each verb appends to the theory and records a provenance entry, so the steps compose cleanly with the native pipe.

theory <- tf_theory("panic-network", "A network theory of panic") |>
  tf_add_construct("c_arousal", "Physiological arousal",
                   "Bodily activation in response to a stressor.",
                   measurement = "heart rate variability",
                   boundary_conditions = "awake adults") |>
  tf_add_construct("c_threat", "Perceived threat",
                   "Appraised danger of bodily sensations.",
                   measurement = "self-report appraisal scale",
                   boundary_conditions = "awake adults") |>
  tf_add_proposition("p1", "c_arousal", "c_threat", "causes",
                     mechanism = "Activation raises the salience of threat cues.") |>
  tf_add_prediction("h1", "Arousal raises threat appraisal by a fixed amount.",
                    "point", derives_from = "p1")

isTRUE(tf_validate(theory)) # structural checks: required fields and enums
#> [1] TRUE
isTRUE(tf_validate(theory, full = TRUE)) # also checks referential integrity of ids and cross-references
#> [1] TRUE

tf_validate() returns TRUE invisibly on success and stops with a message listing every problem otherwise, which is why the calls above are wrapped in isTRUE() to show the result. With full = TRUE it additionally checks referential integrity, that ids are unique and every cross-reference points to a declared id. It does not require any optional dependency.

The failure path is the more informative one. Pointing a prediction at a proposition that was never declared leaves the structural pass untouched, since every required field is still present and well formed, so the problem surfaces only under full = TRUE.

broken <- theory
broken$predictions[[1]]$derives_from <- "p_missing"
tf_validate(broken, full = TRUE)
#> Error:
#> ! invalid theory object: prediction[0] derives_from 'p_missing' is not a known proposition

The building vocabulary has two further verbs. tf_add_assumption() records an auxiliary assumption, and tf_set_formal_model() attaches a formal-model reference. Applied to a copy of the theory, they leave the original untouched.

extended <- theory |>
  tf_add_assumption("a1", "Arousal is measured at rest.", added_for = "h1") |>
  tf_set_formal_model("sem", spec_ref = "panic-sem.lavaan")

Every verb appends a provenance entry, recording the step, the action and its detail, so the record of how a theory was built travels with the object.

do.call(rbind, lapply(extended$provenance, as.data.frame))
#>   step              action        detail
#> 1    1           tf_theory panic-network
#> 2    2    tf_add_construct     c_arousal
#> 3    3    tf_add_construct      c_threat
#> 4    4  tf_add_proposition            p1
#> 5    5   tf_add_prediction            h1
#> 6    6   tf_add_assumption            a1
#> 7    7 tf_set_formal_model           sem

A theory can be written to and read back from disk. The format follows the file extension (.json for JSON, otherwise YAML).

path <- tempfile(fileext = ".yaml")
tf_write(theory, path)
roundtrip <- tf_read(path)
identical(roundtrip$id, theory$id)
#> [1] TRUE

Checking rigour

tf_check() runs the 12-item rigour checklist defined in the vendored rigor_checklist.yaml.

report <- tf_check(theory)

report$aggregate_score   # weighted score, 0-100, rounded to 1 dp
#> [1] 77.6
report$gate              # "pass", "blocked", or "advisory" (draft maturity)
#> [1] "pass"
report$n_blockers_failed # count of failed blocker items
#> [1] 0

# Per-item detail (checklist order preserved):
report$items[[1]]$id      # "falsifiability"
#> [1] "falsifiability"
report$items[[1]]$status  # "pass" / "warn" / "fail"
#> [1] "pass"
report$items[[1]]$score   # numeric in [0, 1]
#> [1] 1

Render it as JSON (valid, pretty-printed) or as an HTML fragment. The JSON string is an artefact in its own right, shown here verbatim:

cat(tf_report(theory, format = "json"))
{
  "theory_id": "panic-network",
  "schema_version": "1.0",
  "maturity": "building",
  "aggregate_score": 77.6,
  "gate": "pass",
  "n_blockers_failed": 0,
  "items": [
    {
      "id": "falsifiability",
      "status": "pass",
      "score": 1,
      "weight": 0.15,
      "severity_if_fail": "blocker",
      "citation": "Popper (1959); Bacharach (1989)"
    },
    {
      "id": "precision",
      "status": "pass",
      "score": 1,
      "weight": 0.1,
      "severity_if_fail": "warning",
      "citation": "Meehl (1967, 1990)"
    },
    {
      "id": "risk_severity",
      "status": "warn",
      "score": 0,
      "weight": 0.1,
      "severity_if_fail": "warning",
      "citation": "Mayo (2018); Meehl (1990)"
    },
    {
      "id": "parsimony",
      "status": "pass",
      "score": 1,
      "weight": 0.08,
      "severity_if_fail": "warning",
      "citation": "Forster & Sober (1994); Lakatos (1970)"
    },
    {
      "id": "non_redundancy",
      "status": "pass",
      "score": 0.857,
      "weight": 0.1,
      "severity_if_fail": "warning",
      "citation": "Kelley (1927); Le et al. (2010); Lawson & Robins (2021)"
    },
    {
      "id": "construct_clarity",
      "status": "pass",
      "score": 1,
      "weight": 0.08,
      "severity_if_fail": "warning",
      "citation": "Suddaby (2010); Cronbach & Meehl (1955); Flake & Fried (2020)"
    },
    {
      "id": "scope",
      "status": "pass",
      "score": 1,
      "weight": 0.06,
      "severity_if_fail": "warning",
      "citation": "Whetten (1989); Bacharach (1989)"
    },
    {
      "id": "logical_why",
      "status": "pass",
      "score": 1,
      "weight": 0.08,
      "severity_if_fail": "warning",
      "citation": "Sutton & Staw (1995); Whetten (1989)"
    },
    {
      "id": "causal_testability",
      "status": "pass",
      "score": 1,
      "weight": 0.06,
      "severity_if_fail": "warning",
      "citation": "Textor et al. (2016); Eronen & Bringmann (2021)"
    },
    {
      "id": "diagnosticity",
      "status": "warn",
      "score": 0,
      "weight": 0.06,
      "severity_if_fail": "warning",
      "citation": "Platt (1964); Fiedler (2017)"
    },
    {
      "id": "formalisation",
      "status": "warn",
      "score": 0,
      "weight": 0.05,
      "severity_if_fail": "warning",
      "citation": "Robinaugh et al. (2021); Guest & Martin (2021)"
    },
    {
      "id": "derivation_chain",
      "status": "pass",
      "score": 1,
      "weight": 0.08,
      "severity_if_fail": "blocker",
      "citation": "Scheel et al. (2021); Szollosi et al. (2020)"
    }
  ]
}

The HTML fragment drops straight into a page and renders as a real table:

cat(tf_report(theory, format = "html"))

Rigour report: panic-network

Aggregate score: 77.6 · gate: pass

item status score grounding
falsifiability pass 1.0 Popper (1959); Bacharach (1989)
precision pass 1.0 Meehl (1967, 1990)
risk_severity warn 0.0 Mayo (2018); Meehl (1990)
parsimony pass 1.0 Forster & Sober (1994); Lakatos (1970)
non_redundancy pass 0.857 Kelley (1927); Le et al. (2010); Lawson & Robins (2021)
construct_clarity pass 1.0 Suddaby (2010); Cronbach & Meehl (1955); Flake & Fried (2020)
scope pass 1.0 Whetten (1989); Bacharach (1989)
logical_why pass 1.0 Sutton & Staw (1995); Whetten (1989)
causal_testability pass 1.0 Textor et al. (2016); Eronen & Bringmann (2021)
diagnosticity warn 0.0 Platt (1964); Fiedler (2017)
formalisation warn 0.0 Robinaugh et al. (2021); Guest & Martin (2021)
derivation_chain pass 1.0 Scheel et al. (2021); Szollosi et al. (2020)

Screening for redundant constructs

The lexical redundancy screen reports the Jaccard similarity of every pair of construct definitions, sorted by descending similarity.

tf_redundancy_check(theory)
#>           a        b similarity flag
#> 1 c_arousal c_threat      0.143   ok

The lexical screen is deterministic but shallow. When a semantic comparison is wanted, tf_embedding_redundancy() takes any embedder that maps a definition to a numeric vector and reports the cosine similarity of each construct pair. It needs no optional dependency, since the embedder is supplied by the caller. A toy bag-of-words embedder stands in for a real language model here.

vocab <- c("bodily", "activation", "appraised", "danger", "salience")
embedder <- function(def) {
  words <- strsplit(tolower(def), "[^a-z]+")[[1]]
  vapply(vocab, function(w) sum(words == w), numeric(1))
}
tf_embedding_redundancy(theory, embedder)
#>           a        b   cosine flag
#> 1 c_arousal c_threat 0.408248   ok

Diagramming

tf_diagram() emits deterministic intermediate representations for several diagram types. The digraphs are Graphviz DOT, and the causal DAG uses dagitty syntax.

cat(tf_diagram(theory, type = "nomological_net"))
digraph nomological_net {
  graph [rankdir=LR, bgcolor="transparent", fontname="Helvetica", fontsize=11, pad="0.2", nodesep="0.3", ranksep="0.45"];
  node [fontname="Helvetica", fontsize=11, shape=box, style="rounded,filled", color="#33567A", fillcolor="#F2F6F9", fontcolor="#12283A", penwidth=1.1, margin="0.16,0.1"];
  edge [fontname="Helvetica", fontsize=10, color="#7B909F", fontcolor="#0F6E6E", arrowsize=0.7];
  "c_arousal" [label="Physiological\narousal", fillcolor="#E4F1F1", color="#1E7B7B"];
  "c_threat" [label="Perceived threat", fillcolor="#E4F1F1", color="#1E7B7B"];
  "c_arousal" -> "c_threat" [label="causes"];
}
cat(tf_diagram(theory, type = "causal_dag"))
dag {
  c_arousal -> c_threat
}

The DOT strings render with any Graphviz tool, and tf_render_diagram() does this without leaving R: it returns a DiagrammeR widget for the viewer and for R Markdown, or a standalone SVG string with as = "svg". The packages it uses are optional, in Suggests, so the deterministic core stays dependency-free. Rendered, the nomological net above reads as a figure.

cat('<div class="tf-figure tf-diagram">', tf_render_diagram(theory, "nomological_net", as = "svg"), '</div>', sep = "")
nomological_net c_arousal Physiologicalarousal c_threat Perceived threat c_arousal->c_threat causes

The causal DAG is the one view this does not cover, since it emits dagitty syntax rather than DOT; paste it into a dagitty tool such as dagitty.net instead.

Simulation

tf_simulate() treats each construct as a state variable and integrates the signed proposition network as a linear dynamical system. The trajectory is deterministic:

sim <- tf_simulate(theory, steps = 5)
unlist(sim$states)
#> [1] "c_arousal" "c_threat"
unlist(sim$trajectory[[1]]) # the common initial state
#> [1] 1 1
unlist(sim$trajectory[[6]]) # after five Euler steps
#> [1] 0.773781 1.181034

The two states separate immediately: arousal, which receives no incoming coupling, decays under the damping term, while perceived threat is pushed up by the positive coupling from arousal before the decay eventually takes over.