A pure, dependency-free Subjective Logic library for Python.
doxa implements Jøsang's Subjective Logic — an algebra of opinions over
Beta-distributed uncertain probabilities. An opinion separates belief,
disbelief, and an explicit uncertainty mass, so a doxa program can say "I
don't know" honestly instead of fabricating a point probability.
Subjective Logic (Jøsang 2001) extends probabilistic logic with a first-class
representation of uncertainty about the probability itself. A binomial
opinion is a tuple ω = (b, d, u, a):
b— belief: mass committed to the proposition being trued— disbelief: mass committed to it being falseu— uncertainty: mass committed to neither — honest ignorancea— base rate (atomicity): the prior probability used to projectu
with the constraint b + d + u == 1 and 0 < a < 1. The projected
probability (expectation) E(ω) = b + a·u collapses an opinion back to a
single number when one is needed, but the uncertainty mass is preserved
everywhere else. A vacuous opinion (0, 0, 1, a) represents total ignorance; a
dogmatic opinion (u = 0) is an ordinary probability.
doxa provides:
Opinion— the binomial opinion type with the full Jøsang operator set: negation, conjunction, disjunction, consensus, trust discounting, uncertainty maximization, and a total ordering.BetaEvidence— the(r, s, a)evidence-count form, with the bijective mapping to and fromOpinion.- Multi-source fusion — N-source Weighted Belief Fusion (WBF) and Consensus
& Compromise Fusion (CCF) from van der Heijden et al. 2018, plus a
fusedispatcher. - Opinion-valued argumentation —
BipolarOpinionGraphandevaluate: a bipolar (support/attack) argument graph whose edges carry opinions, evaluated bottom-up to a per-argumentOpinion. Disagreement between arguments becomes honest uncertainty, not fake confidence.
doxa is a direct implementation of two papers, both shipped under
papers/ with extraction notes:
- Jøsang, A. (2001). A Logic for Uncertain Probabilities. International Journal of Uncertainty, Fuzziness and Knowledge-Based Systems. The original definition of subjective logic — opinion tuples, the Beta-distribution mapping, and the negation, conjunction, disjunction, consensus, and discounting operators.
- van der Heijden, R. W., Kopp, H., & Kargl, F. (2018). Multi-Source Fusion Operations in Subjective Logic. The corrected multi-source fusion operators — N-source Weighted Belief Fusion (WBF) and Consensus & Compromise Fusion (CCF). The kernel's fusion code is regression-tested against Table I of this paper.
Each operator's docstring cites the specific definition or theorem it implements.
The argumentation module is a modest fusion of two mature research lines —
subjective-logic argumentation (which attaches opinions to arguments but
evaluates them with crisp semantics) and gradual QBAF semantics (which
propagates argument strength gradually but only as scalars) — propagating a
full opinion through a bipolar argument graph using only operators already in
the kernel. CCF (van der Heijden et al. 2018, Definition 5) is the accrual
operator; no new algebra is introduced.
doxa is not on PyPI. It installs straight from git, and requires **Python
= 3.11** with zero runtime dependencies.
uv add git+https://github.com/ctoth/doxapip install git+https://github.com/ctoth/doxafrom doxa import Opinion, BetaEvidence
# An opinion is (belief, disbelief, uncertainty, base_rate); b + d + u == 1.
omega = Opinion(b=0.7, d=0.1, u=0.2, a=0.5)
# Named constructors:
ignorant = Opinion.vacuous(a=0.5) # (0, 0, 1, a) — total ignorance
certain_yes = Opinion.dogmatic_true(a=0.5) # (1, 0, 0, a) — absolute belief
certain_no = Opinion.dogmatic_false(a=0.5) # (0, 1, 0, a) — absolute disbelief
# From observed evidence counts (8 positive, 2 negative):
from_obs = Opinion.from_evidence(r=8, s=2, a=0.5)
# From a calibrated probability with an effective sample size:
from_prob = Opinion.from_probability(p=0.8, n=10, a=0.5)
assert from_prob == from_obs # p*n = 8, (1-p)*n = 2expectation() collapses an opinion to a single probability E(ω) = b + a·u:
omega = Opinion(b=0.7, d=0.1, u=0.2, a=0.5)
omega.expectation() # 0.8 (= 0.7 + 0.5 * 0.2)a = Opinion(b=0.6, d=0.2, u=0.2, a=0.5)
b = Opinion(b=0.3, d=0.5, u=0.2, a=0.5)
not_a = ~a # negation: swaps b/d, complements a
both = a.conjunction(b) # conjunction; `a & b` is an alias
either = a.disjunction(b) # disjunction; `a | b` is an aliasPrefer the named conjunction / disjunction methods over & / | when a
reader might confuse them with Python's and / or keywords — those keywords
short-circuit on truthiness and never call the operators. Opinion is
deliberately not truthy: bool(opinion) raises TypeError.
Discounting weakens a source's opinion by how much you trust that source:
trust = Opinion(b=0.8, d=0.1, u=0.1, a=0.5) # how much we trust the source
claim = Opinion(b=0.9, d=0.0, u=0.1, a=0.5) # what the source asserts
discounted = trust.discount(claim) # the claim, seen through trustWhen several sources offer opinions on the same proposition, fuse them:
s1 = Opinion(b=0.10, d=0.30, u=0.60, a=0.5)
s2 = Opinion(b=0.40, d=0.20, u=0.40, a=0.5)
s3 = Opinion(b=0.70, d=0.10, u=0.20, a=0.5)
wbf_result = Opinion.wbf(s1, s2, s3) # Weighted Belief Fusion
ccf_result = Opinion.ccf(s1, s2, s3) # Consensus & Compromise Fusion
auto = Opinion.fuse(s1, s2, s3) # picks WBF, falls back to CCF on dogmatic inputFor the three sources above, wbf yields (b, d, u) = (0.562, 0.146, 0.292)
and ccf yields (0.629, 0.182, 0.189) — the WBF and CCF columns of Table I
in van der Heijden et al. 2018. The two operators are genuinely different: CCF
turns inter-source disagreement into uncertainty rather than fractional
belief.
The older pairwise consensus operator is also available:
consensus_result = Opinion.consensus(s1, s2, s3)BetaEvidence is the evidence-count form, bijective with non-dogmatic
opinions:
evidence = BetaEvidence(r=8, s=2, a=0.5) # 8 positive, 2 negative
opinion = evidence.to_opinion()
back = opinion.to_beta_evidence() # round-trips to r=8, s=2A BipolarOpinionGraph is a bipolar argument graph: each argument carries an
intrinsic Opinion (its own evidence before supporters/attackers; tau = a is
intrinsic[x].a), and each support/attack edge carries an Opinion (the
edge's strength/trust). A leaf resolves to its intrinsic opinion — that is
where belief originates — while a move node's intrinsic is
Opinion.vacuous(tau) (no own evidence). evaluate resolves the graph
bottom-up over the DAG to a per-argument Opinion:
from doxa import BipolarOpinionGraph, Opinion, evaluate
# Move 'm' (tau = 0.55) with one supporter 's' and one objection 'o'.
# 's' and 'o' are leaf arguments carrying intrinsic opinions — their own
# evidence. 'm' is a move node with a vacuous intrinsic (no own evidence).
graph = BipolarOpinionGraph(
arguments=frozenset({"m", "s", "o"}),
intrinsic={
"m": Opinion.vacuous(0.55), # move node — no own evidence
"s": Opinion(0.7, 0.1, 0.2, 0.6), # supporter leaf — its evidence
"o": Opinion(0.4, 0.3, 0.3, 0.5), # objection leaf — its evidence
},
supports=frozenset({("s", "m")}),
attacks=frozenset({("o", "m")}),
edge_opinions={
("s", "m"): Opinion.dogmatic_true(0.5), # fully-trusted edge
("o", "m"): Opinion.dogmatic_true(0.5), # fully-trusted edge
},
)
result = evaluate(graph) # dict[str, Opinion], one entry per argument
omega_m = result["m"] # Opinion(b≈0.516, d≈0.208, u≈0.276, a=0.55)
omega_m.expectation() # ≈ 0.668 (the strong supporter pulls E above tau)The strong supporter raises the move's projected strength above its base
rate, the weaker objection holds it down, and u stays substantial (≈ 0.276)
because the two arguments disagree — disagreement becomes honest uncertainty.
An unargued move argument (vacuous intrinsic, no edges) resolves to
Opinion.vacuous(tau), so expectation() falls back to exactly tau.
evaluate raises CyclicGraphError if the graph contains a cycle.
A frozen, hashable binomial opinion. The constructor enforces b + d + u ≈ 1,
all of b, d, u in [0, 1], and 0 < a < 1. Dogmatic opinions (u == 0)
must pass allow_dogmatic=True.
- Properties:
uncertainty(alias foru),base_rate(alias fora). - Constructors:
vacuous(a),dogmatic_true(a),dogmatic_false(a),from_evidence(r, s, a),from_probability(p, n, a). - Core:
expectation(),uncertainty_interval(),to_beta_evidence(),maximize_uncertainty(). - Operators:
__invert__(~),conjunction/&,disjunction/|, total ordering (<,<=,>,>=),==/hash(both quantizeb, d, u, aonto a shared tolerance grid). - Consensus & trust:
consensus_pair(other),consensus(*opinions),discount(source). - Fusion:
wbf(*opinions),ccf(*opinions),fuse(*opinions, method="auto").
A frozen evidence-count record: r >= 0 positive, s >= 0 negative,
0 < a < 1. to_opinion() maps it to an Opinion. (Converting an Opinion
back uses Opinion.to_beta_evidence(), which raises for dogmatic opinions.)
A frozen bipolar argument graph. Five required fields:
arguments: frozenset[str]— the argument node identifiers.intrinsic: Mapping[str, Opinion]— each argument's own opinion before supporters/attackers;tau = aisintrinsic[x].a. A move node carries a vacuous intrinsic; a leaf carries its evidence.supports: frozenset[tuple[str, str]]— support edges(supporter, target).attacks: frozenset[tuple[str, str]]— attack edges(attacker, target).edge_opinions: Mapping[tuple[str, str], Opinion]— the per-edge strength/trust opinion for every edge.
Construction validates the graph with six checks (raising ValueError):
intrinsic covers exactly arguments; support and attack edges reference
only declared arguments; supports and attacks are disjoint; edge_opinions
has exactly one opinion per edge; no self-loops. The base rate is no longer
range-checked at the graph level — tau is intrinsic[x].a, already validated
to (0, 1) by Opinion's own constructor. Acyclicity is not checked at
construction — it is checked by evaluate.
Resolves every argument bottom-up over the DAG (Kahn's algorithm with a sorted
ready set — deterministic), returning a dict mapping each argument name to its
Opinion. Each argument's opinion is accrued by fusing — with the CCF operator
— its discounted supporters, its negated discounted attackers, and its own
intrinsic opinion when that intrinsic is non-vacuous; the result is re-stamped
with the argument's own base rate. A leaf resolves to its intrinsic opinion.
Raises CyclicGraphError (a ValueError subclass) if the graph contains a
cycle.
doxa ships a py.typed marker, so type checkers consume its inline
annotations directly with no stubs.
uv sync
uv run pytest
uv run pyright