Skip to content

Rubrics

A rubric declares the node types of a graph built from a document's own text, the form each type carries and what each type must rest on. load_rubric reads one from TOML; check_graph(graph, rubric, level) returns every violation as a Finding whose diagnostic is its code; coverage reports how far classification has got; split_node and annotate_node return a new graph with the edit applied. The contract they implement is Verbatim ingestion.

Rubric-driven classification of graphs built from a document's own text.

A rubric declares the node types, their forms and their edge rules. check_graph checks a graph against it at the anchored, typed or assessed level, coverage reports how far the classification has got, and split_node and annotate_node make the edits that classify it.

FormField dataclass

A field of a type's form.

Attributes:

Name Type Description
name str

The field's name.

type str

One of FIELD_TYPES.

required bool

Whether a typed node must have it.

values tuple[str, ...]

The allowed strings of an enum field; empty otherwise.

Source code in src/credencegraph/rubric/model.py
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
@dataclass(frozen=True, slots=True)
class FormField:
    """A field of a type's form.

    Attributes:
        name: The field's name.
        type: One of ``FIELD_TYPES``.
        required: Whether a typed node must have it.
        values: The allowed strings of an ``enum`` field; empty otherwise.
    """

    name: str
    type: str
    required: bool = False
    values: tuple[str, ...] = ()

NodeType dataclass

A node type declared by a rubric.

Attributes:

Name Type Description
name str

The type's name, stored as a node's kind.

description str

What the type is for, shown in reports.

origins tuple[str, ...]

The origins the type may be used with.

assess bool

Whether a node of the type needs an assessment at the assessed level.

base Credence | None

The base annotate gives a node it types, when the node has none.

form Mapping[str, FormField]

The form's fields by name, in declaration order.

rests_on RestsOn | None

The edge rule, if any.

Source code in src/credencegraph/rubric/model.py
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
@dataclass(frozen=True, slots=True)
class NodeType:
    """A node type declared by a rubric.

    Attributes:
        name: The type's name, stored as a node's ``kind``.
        description: What the type is for, shown in reports.
        origins: The origins the type may be used with.
        assess: Whether a node of the type needs an assessment at the ``assessed`` level.
        base: The base ``annotate`` gives a node it types, when the node has none.
        form: The form's fields by name, in declaration order.
        rests_on: The edge rule, if any.
    """

    name: str
    description: str = ""
    origins: tuple[str, ...] = (DOCUMENT,)
    assess: bool = False
    base: Credence | None = None
    form: Mapping[str, FormField] = field(default_factory=dict)
    rests_on: RestsOn | None = None

Parameter dataclass

A parameter that scope fields may range over.

Attributes:

Name Type Description
name str

The parameter's name.

unit str

Its unit, for readers.

domain tuple[float | None, float | None]

(low, high); None is an open end.

Source code in src/credencegraph/rubric/model.py
49
50
51
52
53
54
55
56
57
58
59
60
61
@dataclass(frozen=True, slots=True)
class Parameter:
    """A parameter that ``scope`` fields may range over.

    Attributes:
        name: The parameter's name.
        unit: Its unit, for readers.
        domain: ``(low, high)``; ``None`` is an open end.
    """

    name: str
    unit: str
    domain: tuple[float | None, float | None] = (None, None)

RestsOn dataclass

A type's edge rule: what a node of the type must rest on.

Attributes:

Name Type Description
types tuple[str, ...]

The types a supporting node may have.

min int

How many supporting relations the node needs at least.

relation str

requires or supports: the type of those relations, from the supporting node.

strength Credence

The strength annotate --rests-on gives a relation it creates.

Source code in src/credencegraph/rubric/model.py
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
@dataclass(frozen=True, slots=True)
class RestsOn:
    """A type's edge rule: what a node of the type must rest on.

    Attributes:
        types: The types a supporting node may have.
        min: How many supporting relations the node needs at least.
        relation: ``requires`` or ``supports``: the type of those relations, from the supporting node.
        strength: The strength ``annotate --rests-on`` gives a relation it creates.
    """

    types: tuple[str, ...]
    relation: str
    strength: Credence
    min: int = 1

Rubric dataclass

A rubric: node types, their forms and edge rules, and the parameters scopes range over.

Attributes:

Name Type Description
name str

Identifies the rubric in reports.

version str

The rubric's own version, chosen by its author.

types Mapping[str, NodeType]

The declared types by name, in declaration order.

parameters Mapping[str, Parameter]

The parameters by name.

initial Mapping[str, str]

The type each chunk unit starts with, by unit.

Source code in src/credencegraph/rubric/model.py
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
@dataclass(frozen=True, slots=True)
class Rubric:
    """A rubric: node types, their forms and edge rules, and the parameters scopes range over.

    Attributes:
        name: Identifies the rubric in reports.
        version: The rubric's own version, chosen by its author.
        types: The declared types by name, in declaration order.
        parameters: The parameters by name.
        initial: The type each chunk unit starts with, by unit.
    """

    name: str
    version: str
    types: Mapping[str, NodeType]
    parameters: Mapping[str, Parameter] = field(default_factory=dict)
    initial: Mapping[str, str] = field(default_factory=dict)

    def identity(self) -> dict[str, str]:
        """Return the rubric's name and version, as echoed in every report.

        Returns:
            ``{"name", "version"}``.
        """
        return {"name": self.name, "version": self.version}

identity()

Return the rubric's name and version, as echoed in every report.

Returns:

Type Description
dict[str, str]

{"name", "version"}.

Source code in src/credencegraph/rubric/model.py
139
140
141
142
143
144
145
def identity(self) -> dict[str, str]:
    """Return the rubric's name and version, as echoed in every report.

    Returns:
        ``{"name", "version"}``.
    """
    return {"name": self.name, "version": self.version}

RubricError

Bases: CredenceGraphError

A rubric file is malformed, of an unknown format, or breaks a rule of the rubric format.

Source code in src/credencegraph/rubric/model.py
45
46
class RubricError(CredenceGraphError):
    """A rubric file is malformed, of an unknown format, or breaks a rule of the rubric format."""

annotate_node(graph, rubric, node_id, *, node_type=None, settings=None, rests_on=(), verdict=None, reason=None)

Type, fill in, link and assess a node, validating each write against the rubric.

The steps run in that order, so a type given here is the one the form and links are checked against. Required form fields may be left out: they are filled one at a time, and check_graph at typed reports the ones still missing.

Parameters:

Name Type Description Default
graph Graph

The graph, left unchanged.

required
rubric Rubric

The rubric.

required
node_id str

The node.

required
node_type str | None

A declared type allowed for the node's origin, or unassigned, which removes the type with the node's form, assessment and base. Assigning a type with a base sets it when the node has none.

None
settings Mapping[str, Any] | None

Form fields to set, by name; each must be declared by the node's type and its value must have the field type and be found in the node's text.

None
rests_on Sequence[str]

Nodes to add the type's edge-rule relation from, with the rule's strength.

()
verdict str | None

holds, fails or undetermined.

None
reason str | None

Why the node was not assessed; sets the verdict not_assessed.

None

Returns:

Type Description
Graph

The new graph.

Raises:

Type Description
ValidationError

If any write breaks the rubric, or a relation would close a cycle.

Source code in src/credencegraph/rubric/edit.py
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
def annotate_node(  # noqa: PLR0913 - one argument per kind of annotation
    graph: Graph,
    rubric: Rubric,
    node_id: str,
    *,
    node_type: str | None = None,
    settings: Mapping[str, Any] | None = None,
    rests_on: Sequence[str] = (),
    verdict: str | None = None,
    reason: str | None = None,
) -> Graph:
    """Type, fill in, link and assess a node, validating each write against the rubric.

    The steps run in that order, so a type given here is the one the form and links are checked
    against. Required form fields may be left out: they are filled one at a time, and
    ``check_graph`` at ``typed`` reports the ones still missing.

    Args:
        graph: The graph, left unchanged.
        rubric: The rubric.
        node_id: The node.
        node_type: A declared type allowed for the node's origin, or ``unassigned``, which removes
            the type with the node's form, assessment and base. Assigning a type with a ``base``
            sets it when the node has none.
        settings: Form fields to set, by name; each must be declared by the node's type and its value
            must have the field type and be found in the node's text.
        rests_on: Nodes to add the type's edge-rule relation from, with the rule's strength.
        verdict: ``holds``, ``fails`` or ``undetermined``.
        reason: Why the node was not assessed; sets the verdict ``not_assessed``.

    Returns:
        The new graph.

    Raises:
        ValidationError: If any write breaks the rubric, or a relation would close a cycle.
    """
    node = graph.nodes[node_id]
    origin = origin_of(node)
    if origin is None:
        msg = f"node {node_id!r} has no valid attributes.origin, so the rubric cannot apply to it"
        raise ValidationError(msg)
    if node.kind == COMPOUND:
        msg = f"node {node_id!r} is a compound; annotate its children instead"
        raise ValidationError(msg)
    if verdict is not None and reason is not None:
        msg = "give a verdict or a reason for not assessing, not both"
        raise ValidationError(msg)
    if node_type is not None:
        node = _typed(rubric, node, origin, node_type)
    if settings:
        node = _filled(graph, rubric, node, origin, settings)
    if verdict is not None or reason is not None:
        node = _assessed(rubric, node, verdict, reason)
    relations = _links(graph, rubric, node, rests_on)
    return _rebuilt(graph, {node_id: node}, relations=relations)

check_graph(graph, rubric, level=TYPED)

Check a graph against a rubric at a validation level and every level below it.

Parameters:

Name Type Description Default
graph Graph

The graph.

required
rubric Rubric

The rubric.

required
level str

anchored, typed or assessed.

TYPED

Returns:

Type Description
list[Finding]

Every violation: the anchored ones in graph order, then the typed ones, then the

list[Finding]

assessed ones. Empty when the graph passes. The graph is compiled only at assessed.

Raises:

Type Description
ValueError

If the level is not one of LEVELS.

Source code in src/credencegraph/rubric/validate.py
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
def check_graph(graph: Graph, rubric: Rubric, level: str = TYPED) -> list[Finding]:
    """Check a graph against a rubric at a validation level and every level below it.

    Args:
        graph: The graph.
        rubric: The rubric.
        level: ``anchored``, ``typed`` or ``assessed``.

    Returns:
        Every violation: the ``anchored`` ones in graph order, then the ``typed`` ones, then the
        ``assessed`` ones. Empty when the graph passes. The graph is compiled only at ``assessed``.

    Raises:
        ValueError: If the level is not one of ``LEVELS``.
    """
    if level not in LEVELS:
        msg = f"level must be one of {list(LEVELS)}, got {level!r}"
        raise ValueError(msg)
    structure = _Structure.of(graph)
    findings = _anchored(graph, rubric, structure)
    if level in (TYPED, ASSESSED):
        findings.extend(_typed(graph, rubric, structure))
    if level == ASSESSED:
        findings.extend(_assessed(graph, rubric))
    return findings

coverage(graph, rubric)

Report how far classification has got. Never fails: it is a report, not a gate.

Parameters:

Name Type Description Default
graph Graph

The graph.

required
rubric Rubric

The rubric.

required

Returns:

Type Description
dict[str, object]

{"rubric", "nodes", "by_origin", "by_type", "untyped", "unexamined", "not_assessed"}.

dict[str, object]

by_type counts document nodes only, children and compounds included, in the order types

dict[str, object]

first appear, each with its share of the document nodes. untyped lists the

dict[str, object]

unassigned nodes and unexamined the nodes of assess = true types without an

dict[str, object]

assessment, in graph order. A node without a valid origin is counted in nodes only.

Source code in src/credencegraph/rubric/validate.py
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
def coverage(graph: Graph, rubric: Rubric) -> dict[str, object]:
    """Report how far classification has got. Never fails: it is a report, not a gate.

    Args:
        graph: The graph.
        rubric: The rubric.

    Returns:
        ``{"rubric", "nodes", "by_origin", "by_type", "untyped", "unexamined", "not_assessed"}``.
        ``by_type`` counts document nodes only, children and compounds included, in the order types
        first appear, each with its ``share`` of the document nodes. ``untyped`` lists the
        ``unassigned`` nodes and ``unexamined`` the nodes of ``assess = true`` types without an
        assessment, in graph order. A node without a valid origin is counted in ``nodes`` only.
    """
    by_origin = dict.fromkeys(ORIGINS, 0)
    by_type: dict[str, int] = {}
    untyped: list[str] = []
    unexamined: list[str] = []
    reasons: dict[str, object] = {}
    for node in graph:
        origin = origin_of(node)
        if origin is not None:
            by_origin[origin] += 1
        if origin == DOCUMENT:
            by_type[node.kind] = by_type.get(node.kind, 0) + 1
        if node.kind == UNASSIGNED:
            untyped.append(node.id)
        node_type = rubric.types.get(node.kind)
        assessment = node.attributes.get("assessment")
        if node_type is not None and node_type.assess and assessment is None:
            unexamined.append(node.id)
        if isinstance(assessment, Mapping) and assessment.get("verdict") == NOT_ASSESSED:
            reasons[node.id] = assessment.get("reason")
    documents = by_origin[DOCUMENT]
    return {
        "rubric": rubric.identity(),
        "nodes": len(graph),
        "by_origin": by_origin,
        "by_type": {name: {"count": count, "share": count / documents} for name, count in by_type.items()},
        "untyped": untyped,
        "unexamined": unexamined,
        "not_assessed": {"count": len(reasons), "reasons": reasons},
    }

has_eqref(text, label)

Tell whether a text references an equation.

Parameters:

Name Type Description Default
text str

The text.

required
label str

An equation label, matched by \ref, \eqref, \cref or \Cref, or a printed number, matched as (label).

required

Returns:

Type Description
bool

Whether the text references it.

Source code in src/credencegraph/rubric/faithfulness.py
106
107
108
109
110
111
112
113
114
115
116
117
118
def has_eqref(text: str, label: str) -> bool:
    r"""Tell whether a text references an equation.

    Args:
        text: The text.
        label: An equation label, matched by ``\ref``, ``\eqref``, ``\cref`` or ``\Cref``, or a
            printed number, matched as ``(label)``.

    Returns:
        Whether the text references it.
    """
    commands = ("ref", "eqref", "cref", "Cref")
    return any(f"\\{command}{{{label}}}" in text for command in commands) or f"({label})" in text

has_numeral(text, number)

Tell whether a number appears as a numeral in a text.

Parameters:

Name Type Description Default
text str

The text.

required
number float

The number.

required

Returns:

Type Description
bool

Whether some numeral of the text parses to the same float.

Source code in src/credencegraph/rubric/faithfulness.py
70
71
72
73
74
75
76
77
78
79
80
def has_numeral(text: str, number: float) -> bool:
    """Tell whether a number appears as a numeral in a text.

    Args:
        text: The text.
        number: The number.

    Returns:
        Whether some numeral of the text parses to the same float.
    """
    return float(number) in numerals(text)

has_unit(text, unit)

Tell whether a unit appears in a text as a token, delimited by whitespace or punctuation.

The argument of \mathrm{…}, \text{…} and \si{…} is delimited by its braces, so it counts as a token too. A boundary is required only where the unit begins or ends with a letter or digit: it keeps Hz from matching inside kHz or 100Hz, while a unit that starts with punctuation, such as the % of 1.1%, is itself delimited from the numeral before it. The operators /, ^, ·, ⋅, * and the multiplication sign join symbols into one compound unit, and the subscript operator _ joins a symbol to its subscript, rather than delimit them, so m is not a token of m/s, m^2 or m_e; the compound itself is.

Parameters:

Name Type Description Default
text str

The text.

required
unit str

The unit, such as Hz.

required

Returns:

Type Description
bool

Whether the unit is a token of the text.

Source code in src/credencegraph/rubric/faithfulness.py
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
def has_unit(text: str, unit: str) -> bool:
    r"""Tell whether a unit appears in a text as a token, delimited by whitespace or punctuation.

    The argument of ``\mathrm{…}``, ``\text{…}`` and ``\si{…}`` is delimited by its braces, so it
    counts as a token too. A boundary is required only where the unit begins or ends with a letter
    or digit: it keeps ``Hz`` from matching inside ``kHz`` or ``100Hz``, while a unit that starts
    with punctuation, such as the ``%`` of ``1.1%``, is itself delimited from the numeral before it.
    The operators ``/``, ``^``, ``·``, ``⋅``, ``*`` and the multiplication sign join symbols into one
    compound unit, and the subscript operator ``_`` joins a symbol to its subscript, rather than
    delimit them, so ``m`` is not a token of ``m/s``, ``m^2`` or ``m_e``; the compound itself is.

    Args:
        text: The text.
        unit: The unit, such as ``Hz``.

    Returns:
        Whether the unit is a token of the text.
    """
    before = rf"(?<![^\W_]|{_JOINERS})" if _ALPHANUMERIC.match(unit) else ""
    after = rf"(?![^\W_]|{_JOINERS})" if _ALPHANUMERIC.match(unit[-1:]) else ""
    return re.search(before + re.escape(unit) + after, text) is not None

load_rubric(path)

Read a rubric file.

Parameters:

Name Type Description Default
path str | Path

The TOML file.

required

Returns:

Type Description
Rubric

The rubric.

Raises:

Type Description
RubricError

If the file is not TOML or breaks a rule of the rubric format.

OSError

If the file cannot be read.

Source code in src/credencegraph/rubric/model.py
345
346
347
348
349
350
351
352
353
354
355
356
357
358
def load_rubric(path: str | Path) -> Rubric:
    """Read a rubric file.

    Args:
        path: The TOML file.

    Returns:
        The rubric.

    Raises:
        RubricError: If the file is not TOML or breaks a rule of the rubric format.
        OSError: If the file cannot be read.
    """
    return loads_rubric(Path(path).read_text(encoding="utf-8"))

loads_rubric(text)

Read a rubric from TOML text.

Parameters:

Name Type Description Default
text str

The TOML document.

required

Returns:

Type Description
Rubric

The rubric.

Raises:

Type Description
RubricError

If the text is not TOML or breaks a rule of the rubric format.

Source code in src/credencegraph/rubric/model.py
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
def loads_rubric(text: str) -> Rubric:
    """Read a rubric from TOML text.

    Args:
        text: The TOML document.

    Returns:
        The rubric.

    Raises:
        RubricError: If the text is not TOML or breaks a rule of the rubric format.
    """
    try:
        data = tomllib.loads(text)
    except tomllib.TOMLDecodeError as error:
        msg = f"not valid TOML: {error}"
        raise RubricError(msg) from None
    return rubric_from_dict(data)

normalise_text(text)

Normalise a text for storage: Unicode NFC, whitespace runs collapsed to one space, ends trimmed.

Parameters:

Name Type Description Default
text str

The text as read from the source.

required

Returns:

Type Description
str

The normalised text.

Source code in src/credencegraph/rubric/text.py
18
19
20
21
22
23
24
25
26
27
def normalise_text(text: str) -> str:
    """Normalise a text for storage: Unicode NFC, whitespace runs collapsed to one space, ends trimmed.

    Args:
        text: The text as read from the source.

    Returns:
        The normalised text.
    """
    return _WHITESPACE.sub(" ", unicodedata.normalize("NFC", text)).strip()

numerals(text)

Return the values of every numeral in a text.

A numeral followed by % contributes both its value and its value divided by 100. Values are computed in decimal arithmetic and rounded once, so 1.1% gives exactly the float 0.011.

Parameters:

Name Type Description Default
text str

The text.

required

Returns:

Type Description
set[float]

The values.

Source code in src/credencegraph/rubric/faithfulness.py
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
def numerals(text: str) -> set[float]:
    """Return the values of every numeral in a text.

    A numeral followed by ``%`` contributes both its value and its value divided by 100. Values are
    computed in decimal arithmetic and rounded once, so ``1.1%`` gives exactly the float ``0.011``.

    Args:
        text: The text.

    Returns:
        The values.
    """
    values: set[float] = set()
    for match in _NUMERAL.finditer(text):
        digits = re.sub(_GROUP, "", match["digits"])
        value = Decimal(digits)
        exponent = match["e"] or match["braced"] or match["bare"]
        if exponent is not None:
            value = value.scaleb(int(exponent.replace(_MINUS, "-")))
        if match["sign"] in ("-", _MINUS):
            value = -value
        values.add(float(value))
        if match["percent"]:
            values.add(float(value / 100))
    return values

parse_value(text)

Read a form value from the command line: as JSON if it parses, else as the string itself.

Parameters:

Name Type Description Default
text str

The value as typed.

required

Returns:

Type Description
Any

The value.

Source code in src/credencegraph/rubric/edit.py
201
202
203
204
205
206
207
208
209
210
211
212
213
def parse_value(text: str) -> Any:
    """Read a form value from the command line: as JSON if it parses, else as the string itself.

    Args:
        text: The value as typed.

    Returns:
        The value.
    """
    try:
        return json.loads(text)
    except json.JSONDecodeError:
        return text

rubric_from_dict(data)

Build a rubric from the data of a rubric file.

Parameters:

Name Type Description Default
data Any

The parsed TOML document.

required

Returns:

Type Description
Rubric

The rubric.

Raises:

Type Description
RubricError

If the data breaks a rule of the rubric format, or its format is unknown.

Source code in src/credencegraph/rubric/model.py
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
def rubric_from_dict(data: Any) -> Rubric:
    """Build a rubric from the data of a rubric file.

    Args:
        data: The parsed TOML document.

    Returns:
        The rubric.

    Raises:
        RubricError: If the data breaks a rule of the rubric format, or its format is unknown.
    """
    top = _table(data, "the rubric", {"rubric", "parameters", "initial", "types"}, frozenset({"rubric", "types"}))
    header = _table(top["rubric"], "rubric", {"format", "name", "version"}, frozenset({"format", "name", "version"}))
    if type(header["format"]) is not int or header["format"] != RUBRIC_FORMAT:
        msg = f"rubric.format {header['format']!r} is not a known rubric format; this version reads {RUBRIC_FORMAT}"
        raise RubricError(msg)
    parameters = {}
    for name, value in _table(top.get("parameters", {}), "parameters").items():
        table = _table(value, f"parameters.{name}", {"unit", "domain"}, frozenset({"unit"}))
        domain = _domain(table["domain"], f"parameters.{name}.domain") if "domain" in table else (None, None)
        parameters[name] = Parameter(name, _string(table["unit"], f"parameters.{name}.unit"), domain)
    types_table = _table(top["types"], "types")
    types = {name: _node_type(name, value) for name, value in types_table.items()}
    for node_type in types.values():
        rule = node_type.rests_on
        for name in rule.types if rule else ():
            if name not in types:
                msg = f"types.{node_type.name}.rests_on.types names {name!r}, which is not a declared type"
                raise RubricError(msg)
    initial = {}
    for unit, name in _table(top.get("initial", {}), "initial").items():
        if not isinstance(name, str) or name not in types or DOCUMENT not in types[name].origins:
            msg = f"initial.{unit} names {name!r}, which is not a declared type allowed for document nodes"
            raise RubricError(msg)
        initial[unit] = name
    return Rubric(
        _string(header["name"], "rubric.name"),
        _string(header["version"], "rubric.version"),
        MappingProxyType(types),
        MappingProxyType(parameters),
        MappingProxyType(initial),
    )

split_node(graph, node_id, clauses=(), *, fields=False)

Make a document chunk a compound, with one span child per clause or one field child.

A span child's text is a clause of the chunk, its locator the chunk's plus ;chars=<start>-<end>. A field child has no text, a copy of the chunk's anchor, and gets its content from a form later. Children are <node id>a, <node id>b, … in the order created, joined to the chunk by a part-of relation, and start unassigned. Splitting a compound adds children to it.

Parameters:

Name Type Description Default
graph Graph

The graph, left unchanged.

required
node_id str

The chunk to split.

required
clauses Sequence[str]

The clauses, each occurring exactly once in the chunk's text.

()
fields bool

Add one field child instead of span children.

False

Returns:

Type Description
tuple[Graph, list[str]]

The new graph, and the ids of the children added.

Raises:

Type Description
ValidationError

If the node is not a top-level document chunk with a locator, carries a form, an assessment or a base, takes part in a relation a compound may not, or a clause does not occur exactly once in its text; or if both or neither of clauses and fields is given.

Source code in src/credencegraph/rubric/edit.py
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
def split_node(
    graph: Graph, node_id: str, clauses: Sequence[str] = (), *, fields: bool = False
) -> tuple[Graph, list[str]]:
    """Make a document chunk a compound, with one span child per clause or one field child.

    A span child's text is a clause of the chunk, its locator the chunk's plus ``;chars=<start>-<end>``.
    A field child has no text, a copy of the chunk's anchor, and gets its content from a form later.
    Children are ``<node id>a``, ``<node id>b``, … in the order created, joined to the chunk by a
    ``part-of`` relation, and start ``unassigned``. Splitting a compound adds children to it.

    Args:
        graph: The graph, left unchanged.
        node_id: The chunk to split.
        clauses: The clauses, each occurring exactly once in the chunk's text.
        fields: Add one field child instead of span children.

    Returns:
        The new graph, and the ids of the children added.

    Raises:
        ValidationError: If the node is not a top-level document chunk with a locator, carries a form,
            an assessment or a base, takes part in a relation a compound may not, or a clause does not
            occur exactly once in its text; or if both or neither of ``clauses`` and ``fields`` is given.
    """
    if bool(clauses) == fields:
        msg = "split needs either clauses or fields, not both"
        raise ValidationError(msg)
    node = graph.nodes[node_id]
    _check_splittable(graph, node)
    anchor = node.sources[0]
    spans = []
    for clause in clauses:
        starts = _occurrences(node.statement, clause) if clause else []
        if len(starts) != 1:
            msg = f"clause {clause!r} occurs {len(starts)} times in the text of {node_id!r}, not exactly once"
            raise ValidationError(msg)
        spans.append((starts[0], clause))
    unit = node.attributes.get("unit")
    base_attributes = {"origin": DOCUMENT, **({"unit": unit} if unit is not None else {})}
    suffixes = (suffix for suffix in _suffixes() if f"{node_id}{suffix}" not in graph)
    children = []
    for start, clause in spans or [(None, None)]:
        child_id = f"{node_id}{next(suffixes)}"
        if start is None:
            child = Node(child_id, kind=UNASSIGNED, sources=node.sources, attributes=dict(base_attributes))
        else:
            locator = f"{anchor.locator};chars={start}-{start + len(clause)}"
            source = SourceAnchor(anchor.document, locator, clause, text_digest(clause))
            child = Node(
                child_id, kind=UNASSIGNED, statement=clause, sources=(source,), attributes=dict(base_attributes)
            )
        children.append(child)
    relations = [Relation(f"{child.id}-{PART_OF}-{node_id}", PART_OF, child.id, node_id) for child in children]
    for relation in relations:
        if relation.id in graph.relations:
            msg = f"a relation with id {relation.id!r} already exists"
            raise ValidationError(msg)
    existing = [
        relation.source
        for relation in graph.relations.values()
        if relation.type == PART_OF and relation.target == node_id
    ]
    order = list(graph.nodes)
    after = max([node_id, *existing], key=order.index)
    compound = replace(node, kind=COMPOUND)
    return _rebuilt(graph, {node_id: compound}, after, children, relations), [child.id for child in children]

text_digest(text)

Return the digest of a text: sha256: and the hex SHA-256 of its normalised UTF-8 bytes.

Parameters:

Name Type Description Default
text str

The text.

required

Returns:

Type Description
str

The digest, such as sha256:9f86d0....

Source code in src/credencegraph/rubric/text.py
30
31
32
33
34
35
36
37
38
39
def text_digest(text: str) -> str:
    """Return the digest of a text: ``sha256:`` and the hex SHA-256 of its normalised UTF-8 bytes.

    Args:
        text: The text.

    Returns:
        The digest, such as ``sha256:9f86d0...``.
    """
    return DIGEST_PREFIX + hashlib.sha256(normalise_text(text).encode("utf-8")).hexdigest()