Judge induced fact types with two skills
When extracted facts land in a generic fact type, "an item has an attribute
with a value", gnd discover --families fact-type groups the rows by
attribute and proposes a proper binary fact type for each attribute that
recurs. It counts well.
Two things it cannot decide by itself. Does a group
mean the same as a fact type the model already has? The same role players say
only that two fact types could hold the same facts. And how should an
attribute be modelled when an item holds several values for it? The family
counts such a group multi_valued and stops.
Those two judgements belong to two packaged skills, recorded here in Claude Code,
gnd-judge-fact-types and gnd-model-multivalued. The CLI stays
deterministic. It writes value-free evidence, reads back the skill's verdicts
bound by digest to that evidence, checks them, and renders evolve proposals
that gnd evolve preview --policy additive accepts. Applying stays your
reviewed gnd evolve apply, which this page names and does not run.
Source: the two rounds of the shipped example, run below.
Files on GitHub
type_judgements/duplicates/: a model with one applied induction, renamed by review, and a hand-modelled "Item has colour Colour"; a population of four attributes; andduplicates.judgements.json, the filegnd-judge-fact-typeswrote in a recorded sessiontype_judgements/multi-valued/: a model whose applied induction is many-to-many, and a hand-modelled "Item has label Label"; a population of five attributes with several values per item; andshapes.judgements.json, the filegnd-model-multivaluedwrote in a recorded session- the example's README
- this page's Markdown source
Copy the example into a working folder:
python -c "import gnd, pathlib, shutil; shutil.copytree(pathlib.Path(gnd.__file__).parent / 'examples/type_judgements', 'items', dirs_exist_ok=True)"
Both folders are round two of the same story: one induction was proposed, reviewed and applied, and now the generic fact type has filled up again.
The attribute names in the populations are deliberately meaningless, so a judge that reads only the evidence cannot see them, and this page does not print them.
There are two folders rather than one evidence file because each recorded judgements file is bound by digest to the evidence of its own folder, its own model and its own population; the two rounds have different models, so neither file could bind the other's evidence.
Round one, in a sentence
The duplicates folder's model is what the first round left. The family
proposed item_has_attribute_induced_0 from one attribute, review renamed its
reading to "{Item} has dimension {AttributeValue}" and applied it; the
model's induced_from provenance remembers the attribute it came from, as a
digest. Someone also modelled "{Item} has colour {Colour}" by hand. The
population still holds every row in the generic fact type: nothing moves when
a fact type is induced.
Run the family again and ask for evidence:
gnd discover items/duplicates/model.orm.yaml items/duplicates/items.population.json --families fact-type --type-evidence items/duplicates/evidence.json --out items/duplicates/proposal-0.json --report items/duplicates/report-0.json --actor you --requested-at 2026-11-05T00:00:00+00:00
GND DISCOVER
MODEL items/duplicates/model.orm.yaml
POPULATION items/duplicates/items.population.json
EMITTED 0
NEAR MISSES 0
REJECTED vacuous=0 unchecked=0 fail=0 warning=0
SUPPRESSED
FACT TYPES 0
shape of item_has_attribute_induced_0
shape of item_has_attribute_induced_0
shape of item_has_attribute_induced_0
shape of item_has_attribute_induced_0
WORK total=0
PROPOSAL (none)
Nothing is proposed. Every group of one generic fact type has the same role players, Item and AttributeValue, so once one induced fact type is applied, every later group is a shape match against it. That is the limit the first skill lifts.
The evidence
The evidence file is what the family knows and nothing more: for each group, its status, its counts and opaque row ids, and for every other fact type the item plays (the candidates), its readings and two overlaps: how many of the group's items play it, and how many of the group's (item, value) pairs it already holds.
A candidate induced from this very attribute carries an
origin. No attribute name and no value appears in it; the two populations'
and the model's digests do. The format is in the
type evidence reference.
python -c "import json; e=json.load(open('items/duplicates/evidence.json')); print(e['format'], 'show_values', e['show_values'], 'truncated_groups', e['truncated_groups']); [print(g['group'], g['status'], 'rows', g['rows'], 'items', g['support'], [(c['fact_type'], c['overlap']['pairs'], bool(c['origin'] and c['origin']['same_attribute'])) for c in g['candidates']]) for g in e['groups']]"
gnd-type-evidence/1 show_values False truncated_groups 0
g0 shape_match rows 24 items 24 [('item_has_attribute_induced_0', 24, True), ('item_has_colour', 0, False)]
g1 shape_match rows 24 items 24 [('item_has_attribute_induced_0', 0, False), ('item_has_colour', 24, False)]
g2 shape_match rows 24 items 24 [('item_has_attribute_induced_0', 0, False), ('item_has_colour', 0, False)]
g3 shape_match rows 24 items 24 [('item_has_attribute_induced_0', 0, False), ('item_has_colour', 12, False)]
Each tuple is a candidate: its id, the pairs it shares with the group, and whether it was induced from the group's own attribute. Read as a table:
| Group | Rows | Shares pairs with ..._induced_0 |
Shares pairs with item_has_colour |
Same attribute as ..._induced_0 |
|---|---|---|---|---|
| g0 | 24 | all 24 | none | yes |
| g1 | 24 | none | all 24 | no |
| g2 | 24 | none | none | no |
| g3 | 24 | none | 12 of 24 | no |
Judge the duplicates
You type. From inside items/duplicates, where the model and the
evidence sit, the recorded session's first turn was exactly:
Claude Code does. The skill checks the evidence before it judges: the
format, that model_sha256 is the digest of the model it was given, and that
no group was left out.
Then it applies four rules to each group, in order, and stops where they stop. In plain words:
- R1. A candidate induced from this very attribute is the same fact type.
- R2. A candidate that already holds nearly all of the group's pairs is the same fact type under another name.
- R3. When every comparable candidate shares no pair at all, the group is distinct, and the skill says that this rests only on the absence of shared facts.
- R4. Anything in between is a question for you: a partial overlap, no candidate it can compare, or a candidate whose pairs redaction hides.
Equal role players are evidence neither way.
The recorded agent (Sonnet 5.5) decided g0, g1 and g2 and asked about g3. Its reply began:
I haven't written a judgements file. Group
g3needs your decision first, and the skill says to write nothing until every eligible group has a verdict.
and ended with the question, after noting that item_has_colour shares half
of g3's pairs, below the bar for R2 and above zero for R3:
Is
g3the same attribute as colour, only partly recorded initem_has_colour? Or is it a different attribute for the same items?
duplicateofitem_has_colour: choose this if it is colour, and the rest of its values are simply missing from the model's fact type.distinct: choose this if it is a separate attribute that happens to agree with colour on half the items.
You answer. The recorded second turn:
You get. The shipped items/duplicates/duplicates.judgements.json is the
file the agent then wrote. Its reply began:
I wrote the judgements file to
<workspace>/duplicates.judgements.json. I haven't rungndon it, so the CLI hasn't checked it yet.
and it gave the two commands of the next section with placeholders for you to fill. The file binds the evidence by the SHA-256 of its raw bytes, which is why the digest you compute here equals the one in the file:
python -c "import hashlib, json; print(hashlib.sha256(open('items/duplicates/evidence.json', 'rb').read()).hexdigest()); print(json.load(open('items/duplicates/duplicates.judgements.json'))['evidence_sha256'])"
75c734f87cfc1702eca3746c2cdd4bbac2d33012e7db405c6d85867cff83717b
75c734f87cfc1702eca3746c2cdd4bbac2d33012e7db405c6d85867cff83717b
One verdict per group, in evidence order, each with the rule that decided it:
python -c "import json; [print(v['group'], v['verdict'], v.get('of', '-'), v['rationale'].split(':')[0]) for v in json.load(open('items/duplicates/duplicates.judgements.json'))['verdicts']]"
g0 duplicate item_has_attribute_induced_0 Rule R1
g1 duplicate item_has_colour Rule R2
g2 distinct - Rule R3
g3 distinct - Rule R4
Source: the evidence and the judgements file printed above.
The rationales name group ids, fact type ids, words from the model's readings and the rule. They name no attribute and no value, and, because the evidence hides values, no number standing alone: g1's says "every distinct pair" rather than a count, and the agent is "Claude Sonnet" with no version. A short value could equal a count or a version, and the CLI refuses free text that matches one.
Render the verdicts
Claude Code gives you two commands. The first runs the family once more,
with the evidence it wrote and the judgements the skill wrote, under the same
model, population, --min-support and disclosure setting:
gnd discover items/duplicates/model.orm.yaml items/duplicates/items.population.json --families fact-type --type-evidence-in items/duplicates/evidence.json --duplicate-judgements items/duplicates/duplicates.judgements.json --out items/duplicates/proposal.json --report items/duplicates/report.json --actor you --requested-at 2026-11-05T00:00:00+00:00
GND DISCOVER
MODEL items/duplicates/model.orm.yaml
POPULATION items/duplicates/items.population.json
EMITTED 0
NEAR MISSES 0
REJECTED vacuous=0 unchecked=0 fail=0 warning=0
SUPPRESSED
FACT TYPES 2
item_has_attribute_induced_2 support=24
item_has_attribute_induced_3 support=24
shape of item_has_attribute_induced_0
shape of item_has_colour
WORK total=0
PROPOSAL items/duplicates/proposal.json
GND says. Before rendering anything it computed the evidence again, byte for byte, and compared it with the file the judgements bind; had the population or the model changed, or the disclosure setting, it would have refused and named the setting that differs.
It then checked every
verdict against the evidence: a group may not be missing, repeated or of
another kind; of must name a candidate of that group; and distinct is
refused on a group whose attribute some fact type's induced_from already
names, whether or not the judge noticed. The rules the CLI holds a judgements
file to are in the type judgements
reference.
Then it rendered. The two distinct groups are proposed even though their
signature matches item_has_attribute_induced_0; the two duplicate groups
are listed as shape matches against the fact type the judge named, not
against the first fact type with the same shape as before:
python -c "import json; p=json.load(open('items/duplicates/report.json'))['fact_type_proposals']; [print('proposed', x['fact_type'], 'judged', x['judged']) for x in p['proposed']]; [print('shape match', m['existing'], 'judged', m['judged']) for m in p['shape_matches']]"
proposed item_has_attribute_induced_2 judged True
proposed item_has_attribute_induced_3 judged True
shape match item_has_attribute_induced_0 judged True
shape match item_has_colour judged True
Each proposed fact type carries a placeholder reading for a reviewer to rename, and in its provenance both where it came from and who judged it:
python -c "import json; [print(op['after']['reading'], '|', op['after']['provenance']['judgement']['skill'], op['after']['provenance']['judgement']['verdict']) for op in json.load(open('items/duplicates/proposal.json'))['operations'] if op['after'].get('reading')]"
{Item} has induced item_has_attribute attribute 2 {AttributeValue} | gnd-judge-fact-types distinct
{Item} has induced item_has_attribute attribute 3 {AttributeValue} | gnd-judge-fact-types distinct
The second command previews the round under the additive policy, as in grow a model by additions:
gnd evolve preview items/duplicates/model.orm.yaml items/duplicates/proposal.json --workspace items/duplicates --records items/duplicates/records --out items/duplicates/preview.json --policy additive
...
breaking: false
affected: fact_types[item_has_attribute_induced_2]
affected: fact_types[item_has_attribute_induced_2].constraints[uniqueness:new:0]
affected: fact_types[item_has_attribute_induced_3]
affected: fact_types[item_has_attribute_induced_3].constraints[uniqueness:new:0]
policy: additive
populated: -
Admitted: two new fact types, each with a uniqueness on the item role that stays induced, proposed and deontic. Applying it is your step, with the same policy and after reading the preview:
gnd evolve apply items/duplicates/model.orm.yaml items/duplicates/proposal.json --preview items/duplicates/preview.json --workspace items/duplicates --records items/duplicates/records --policy additive
This page does not run it. Review can rename the placeholder readings, and
it can still overturn a verdict: g2's distinct rests on the absence of
shared facts, and a synonym attribute recorded for different items would
pass R3 the same way.
The multi-valued round
The second folder's first round went differently: the induced attribute held
several values per item, so review applied item_has_attribute_induced_0 as
"{Item} is tagged {AttributeValue}" with a uniqueness spanning both roles.
Someone also modelled "{Item} has label {Label}" by hand, with the same
spanning uniqueness. Now five more attributes have several values per item.
gnd discover items/multi-valued/model.orm.yaml items/multi-valued/items.population.json --families fact-type --type-evidence items/multi-valued/evidence.json --out items/multi-valued/proposal-0.json --report items/multi-valued/report-0.json --actor you --requested-at 2026-11-05T00:00:00+00:00
GND DISCOVER
MODEL items/multi-valued/model.orm.yaml
POPULATION items/multi-valued/items.population.json
EMITTED 0
NEAR MISSES 0
REJECTED vacuous=0 unchecked=0 fail=0 warning=0
SUPPRESSED
FACT TYPES 0
WORK total=0
PROPOSAL (none)
Not even a shape match: a multi-valued group has no signature, because the family does not know what shape to give it. The evidence says how many items hold several values and how many rows repeat an (item, value) pair exactly:
python -c "import json; [print(g['group'], g['status'], 'rows', g['rows'], 'items', g['support'], 'with several values', g['multi']['entities'], 'exact repeats', g['exact_repeats'], [(c['fact_type'], c['overlap']['pairs'], bool(c['origin'] and c['origin']['same_attribute'])) for c in g['candidates']]) for g in json.load(open('items/multi-valued/evidence.json'))['groups']]"
g0 multi_valued rows 48 items 24 with several values 24 exact repeats 0 [('item_has_attribute_induced_0', 48, True), ('item_has_label', 0, False)]
g1 multi_valued rows 48 items 24 with several values 24 exact repeats 0 [('item_has_attribute_induced_0', 0, False), ('item_has_label', 48, False)]
g2 multi_valued rows 37 items 24 with several values 12 exact repeats 1 [('item_has_attribute_induced_0', 0, False), ('item_has_label', 0, False)]
g3 multi_valued rows 25 items 24 with several values 1 exact repeats 0 [('item_has_attribute_induced_0', 0, False), ('item_has_label', 0, False)]
g4 multi_valued rows 48 items 24 with several values 24 exact repeats 0 [('item_has_attribute_induced_0', 0, False), ('item_has_label', 24, False)]
You type. From inside items/multi-valued, the recorded first turn:
Claude Code does. This skill borrows a convention from schema-to-ORM work: an attribute with several values per item is a junction table, one many-to-many fact type with a uniqueness over both roles, which says each (item, value) pair is recorded once and nothing about how many values an item may have.
Its four rules, in plain words:
- S1. A candidate induced from this very attribute already models it, so skip. If that candidate's uniqueness covers the item alone, the data has changed shape, and that is a job for a reviewed model change, not a judgement.
- S2. A candidate that already holds nearly all the pairs covers it, so skip.
- S3. When nothing was truncated, no candidate shares a pair, and at least one item in ten holds several values, it is a junction table, many-to-many. The rows that repeat a pair become the uniqueness's counterexamples.
- S4. Anything else is a question for you, including an attribute where only a few items hold several values, which may be entry errors.
A shapes file can say many-to-many or skip; it cannot declare an attribute single-valued.
The recorded agent decided g0, g1 and g2, and read the model to confirm that the applied fact type's uniqueness spans both roles before applying S1. It began:
I've checked the inputs and decided three of the five groups. I haven't written the judgements file, because g3 and g4 need your answer first.
and asked two questions:
- g3 (S4, few multi-valued entities). Only one item has two values; the other 23 have one. ... Is that second value a real second value, or an entry error?
- If it's real, the verdict is
many-to-many.If it's an error, the verdict is
skip. You would then correct the population and regenerate the evidence, because a shapes file can't declare an attribute single-valued.g4 (S4, partial overlap).
item_has_labelalready holds 24 of the group's 48 distinct pairs, about half. Should this be:many-to-many, kept as its own fact type even though half its pairs are already initem_has_label?skip, because it's meant to be covered byitem_has_label?
You answer. The recorded second turn:
In group g3 the one item's second value is an entry error: skip it, and the population will be fixed. Group g4 repeats labels the model already holds: skip it too. Write the file.
You get. The shipped items/multi-valued/shapes.judgements.json. The
agent's reply closed with:
Only g2 is rendered as a proposal, a binary fact type with a uniqueness over both roles. The four skips are counted under
skipped.judged_skip.
and, of g3, it repeated what a skip means here: correct the population, then make new evidence, because the binding will stop matching once the population changes.
python -c "import json; [print(v['group'], v['verdict'], v['rationale'].split(':')[0]) for v in json.load(open('items/multi-valued/shapes.judgements.json'))['verdicts']]"
g0 skip Rule S1
g1 skip Rule S2
g2 many-to-many Rule S3
g3 skip Rule S4, answered by the user
g4 skip Rule S4, answered by the user
Render:
gnd discover items/multi-valued/model.orm.yaml items/multi-valued/items.population.json --families fact-type --type-evidence-in items/multi-valued/evidence.json --shape-judgements items/multi-valued/shapes.judgements.json --out items/multi-valued/proposal.json --report items/multi-valued/report.json --actor you --requested-at 2026-11-05T00:00:00+00:00
GND DISCOVER
MODEL items/multi-valued/model.orm.yaml
POPULATION items/multi-valued/items.population.json
EMITTED 0
NEAR MISSES 0
REJECTED vacuous=0 unchecked=0 fail=0 warning=0
SUPPRESSED
FACT TYPES 1
item_has_attribute_induced_2 support=36
WORK total=0
PROPOSAL items/multi-valued/proposal.json
One proposal, with the shape the judge gave it, and four judged skips:
python -c "import json; p=json.load(open('items/multi-valued/report.json'))['fact_type_proposals']; [print('proposed', x['fact_type'], x['shape'], 'support', x['support']['numerator'], 'of', x['support']['denominator']) for x in p['proposed']]; print('judged_skip', p['skipped']['judged_skip'])"
Support for a many-to-many fact type is distinct pairs over rows: 36 of g2's 37 rows are distinct (item, value) pairs. The one row that repeats a pair is the counterexample of the proposed uniqueness, which spans both roles:
python -c "import json; [print(op['after']['type'], 'over', op['after']['roles'], 'counterexamples', op['after']['provenance']['induction']['counterexamples']) for op in json.load(open('items/multi-valued/proposal.json'))['operations'] if op['after'].get('type') == 'uniqueness']"
ft0#132 is an opaque row id: the 133rd row of the generic fact type, which
states a pair some earlier row already stated. Nothing is wrong with the
proposal; a deontic uniqueness with one counterexample says exactly what the
data says, and a reviewer who applies it knows which row to look at.
Source: the evidence, the judgements file and the report printed above.
gnd evolve preview items/multi-valued/model.orm.yaml items/multi-valued/proposal.json --workspace items/multi-valued --records items/multi-valued/records --out items/multi-valued/preview.json --policy additive
...
breaking: false
affected: fact_types[item_has_attribute_induced_2]
affected: fact_types[item_has_attribute_induced_2].constraints[uniqueness:new:0]
policy: additive
populated: -
Admitted, and again gnd evolve apply with the same policy is your step.
When a run has both kinds of group, both judgements files go to one
gnd discover call, --duplicate-judgements beside --shape-judgements.
What this proves, and what it does not
The two rounds establish that a skill's verdicts can drive the family without the CLI ever choosing one: each verdict is checked against the evidence it was made from, each rendered fact type records the evidence and judgements digests that produced it, and the result is an ordinary additive proposal that review admits or overturns. A verdict is a proposal, not a change.
The recorded sessions also show the skills stopping where their
rules stop and asking, writing nothing until every group had a verdict. The
recorder checked that the file was written only after the question, that no
gnd evolve apply was called and that no attribute name reached the file.
It raised one false alarm. In the multi-valued recording, results.json
still says no_population_access: false: the check matched the word
"population" in text the agent wrote into its own file, not a read of the
population. The check now skips text an agent writes, and run again over the
kept streams it finds no read. The recording is kept as it ran, the
milestone's TS4 phase record explains the false alarm, and a test pins the
corrected check.
Three limits. The CLI's free-text check catches the exact text of a group's attribute or value, ignoring case and whitespace; it does not catch a paraphrase or an encoding, so the skills' own rule never to quote a value is the main control, and the digests in the file are what makes an audit possible, not a guarantee that the rationale is clean.
There is one recorded run per skill over one small model; the rules were followed here, which says nothing about a model with a hundred candidates or an evidence file near its size cap.
And the skills never read the population, which is the point of
the evidence, but also means the duplicates round's distinct for g2 and the multi-valued
round's skip for g3 rest on counts
and your word; a synonym recorded for different items, or a second value
that was real, would pass the same way. Review is where those are caught.
Recap
| Round | The family said | The skill judged | GND rendered |
|---|---|---|---|
| duplicates | four shape matches, nothing proposed | g0, g1 duplicate (R1, R2); g2 distinct (R3); g3 asked, distinct (R4) |
two proposed fact types, two judged shape matches; admitted |
| multi-valued | five multi-valued groups, nothing proposed | g0, g1 skip (S1, S2); g2 many-to-many (S3); g3, g4 asked, skip (S4) |
one many-to-many fact type with a spanning uniqueness and one counterexample row; admitted |
In both rounds gnd discover ran twice, once to write the evidence and once
to read the verdicts back and check them, with a recorded Claude Code session
between the two that read the evidence and never the population. Applying is
still yours.
Next steps
- The evidence and judgements formats, the binding rule and every refusal are in the type evidence and type judgements reference.
- The skills themselves:
gnd-judge-fact-typesandgnd-model-multivalued; install them with the agent skill guide. - The round that applies a proposal, with the policy that keeps it additive, is grow a model by additions and the additive evolve policy reference.
- How generic rows get into a population in the first place, from several documents at once, is from several documents to one checked answer.