Eyu.Core
0.23.0
dotnet add package Eyu.Core --version 0.23.0
NuGet\Install-Package Eyu.Core -Version 0.23.0
<PackageReference Include="Eyu.Core" Version="0.23.0" />
<PackageVersion Include="Eyu.Core" Version="0.23.0" />
<PackageReference Include="Eyu.Core" />
paket add Eyu.Core --version 0.23.0
#r "nuget: Eyu.Core, 0.23.0"
#:package Eyu.Core@0.23.0
#addin nuget:?package=Eyu.Core&version=0.23.0
#tool nuget:?package=Eyu.Core&version=0.23.0
Eyu
Eyu (이유) — Korean for "reason" or "why". Pronounced roughly "eh-yoo".
A source-agnostic ontology inference engine: turns declared structure and raw records into proposed entities, relations, and grounded claims — the reason a piece of data is shaped the way it is, made explicit and citable.
Status: contract implemented; grounding integrity and response parsing validated against a real
model; entity-resolution accuracy measured on labeled public benchmarks — B-cubed F1 0.99–1.00 on Febrl
synthetic person records, and pair-level F1 0.93–1.00 from the pre-filter alone on clean structured
entity-matching sets, where product, dirty and long-text sets leave most pairs to the model (details in
Status); ontology extraction measured on two public tasks — triple F1 0.35 and 0.22 on
two Text2KGBench ontologies, and is-a precision 0.92, recall 0.56 on a ten-sample pilot of the
LLMs4OL 2026 Reuse task; used by one external package consumer on a Korean-language document corpus. All five ports exist (Eyu.Core), with a Formbase source adapter
(Eyu.Formbase) and an RDF/OWL writer (Eyu.Rdf), all three on NuGet. What
each measurement establishes — and what it does not, starting with the absence of a human-reviewed
accuracy figure on real domain data — is in docs/status.md.
Install
Current release: 0.23.0. All three packages ship from this repository and move together:
dotnet add package Eyu.Core --version 0.23.0
dotnet add package Eyu.Formbase --version 0.23.0
dotnet add package Eyu.Rdf --version 0.23.0
Eyu.Core alone is enough to implement the five ports against your own source; Eyu.Formbase is
the adapter for one of them and reads Formbase.Core as a package, so it pairs with a Formbase
release — this one is built against Formbase.* 0.34.0. Eyu.Rdf writes a proposal as OWL in Turtle
(see Exporting as RDF/OWL).
Every package is AOT-compatible (IsAotCompatible): a Native AOT or trimmed host publishes and runs
Eyu without trim or AOT warnings, which CI checks by publishing and running a Native AOT executable.
Quick start
Hand Eyu a few records, get back the entities, relations and grounded claims it proposes — then, optionally, the same proposal as OWL:
using System.Net.Http.Headers;
using Eyu.Core.Declared;
using Eyu.Core.Inference.Http;
using Eyu.Core.Judgment;
using Eyu.Core.Primitives;
using Eyu.Core.Records;
using Eyu.Rdf;
// Any OpenAI-compatible server. Requests go to "chat/completions" relative to the base address,
// so it ends with the API version segment and a slash.
var http = new HttpClient
{
BaseAddress = new Uri("https://api.openai.com/v1/"),
Timeout = TimeSpan.FromMinutes(5), // a thinking model can take minutes per call
};
http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("OPENAI_API_KEY"));
var model = new HttpModelClient(http, "gpt-4o-mini");
// Records are handed in, never fetched: an id, and the fields as the source holds them.
RawRecord[] records =
[
new("emp-1", new Dictionary<string, string?> { ["name"] = "Kim Minji", ["email"] = "minji.kim@example.com" }),
new("emp-2", new Dictionary<string, string?> { ["name"] = "Minji Kim", ["email"] = "minji.kim@example.com" }),
new("wo-1", new Dictionary<string, string?> { ["title"] = "Replace bearing on press #4", ["reviewer"] = "Kim Minji" }),
];
// Declare what you already know -- here only that some records are employees. Declared always wins;
// the model proposes the rest.
DeclaredStructure[] declared = [new(SubjectRef.Create("Employee"), [], [])];
var proposal = await new SinglePassOntologyProposer(model).ProposeAsync(declared, records);
foreach (var entity in proposal.Entities)
{
Console.WriteLine($"{entity.Name} : {entity.EntityType} ({entity.Confidence:0.00}), denoted by {string.Join(", ", entity.DenotedBy)}");
}
foreach (var relation in proposal.Relations)
{
Console.WriteLine($"{relation.FromEntityId} -{relation.RelationName}-> {relation.ToEntityId}: {relation.Claim.Claim}");
}
Console.WriteLine($"{proposal.Rejections.Count} element(s) left out, each with its reason in proposal.Rejections");
Console.WriteLine(OntologyTurtle.ToTurtle(proposal, new RdfExportOptions(new Uri("https://example.org/plant#"))));
The two employee rows are one person written two ways. Whether they come back as one entity denoted
by both is the proposal's judgment, and DenotedBy shows which it made — two runs of this very
sample have answered both ways, which is why a proposal is routed rather than applied (Status, above,
says what is and is not measured). A self-hosted thinking model
can be told not to think through extraBody (see Ports); a failed call throws
HttpRequestException naming the status, the request URI and what the server answered.
Why
Every system that wants an ontology chatbot ends up rebuilding the same brain: infer entities and relations from structure, decide how confident that inference is, and refuse to answer without a citable path back to the source. That logic has nothing to do with where the data lives — whether it's a raw document store you own, a schema you declared elsewhere, or a table in someone else's database you can only read. Building it once, separately from any storage or access model, is the only way it doesn't get rebuilt every time a new consumer needs it.
This is the bet the design makes, not yet a cross-validated claim: today Eyu
has one source adapter (Eyu.Formbase, in this same repo) and one external
package consumer, on one document corpus (see Status). Read "every
system" as the target the architecture is built toward, not as a track record.
The idea
[structure hints] ──┐
├─▶ Eyu ─▶ proposed entities / relations
[raw records] ──┘ + confidence
+ {claim, sources[], path[]}
- Input, not fetch. Eyu never pulls data. A caller hands it declared
structure (field hints, a schema, an M3L-style declaration) and/or raw
records to look at. What the caller doesn't supply, Eyu doesn't know.
That includes its own earlier answers, which a caller that keeps proposals
hands back as
PriorKnowledge. It can name the entities they identified as known entities (KnownEntity— a key of the caller's choosing, the name and type, and the records that denoted it), and an entity the new records show that is one of them comes back with that key inKnownEntityKeyinstead of as a new entity. Eyu keeps nothing between calls; the known entity's records are compared, never cited. An entity earlier records only mentioned — a work order naming its machine, with no record of the machine itself — is not known: nothing said what it is. Hand it back as aMentionedEntity(key, name, type, the records that mentioned it), and when a later call meets a record of a thing that may be it, the proposal lists aMergeCandidate(the entity, the mentioned key, a grounded claim, a confidence) inMergeCandidates. A candidate is not an identity — whether to join the two is the caller's decision — so identity does not depend on which source arrives first, and no mention is ever promoted to a known entity. And it can name the type names already in use (KnownType— the name, and records of its kind as specimens): an entity of a kind one of them names comes back under that name, spelled as it is in use (WorkOrderanswered forwork_orderis writtenwork_order), rather than under a new name for the same kind. A known entity's type counts as a name in use too. Whether a kind is the one a name stands for stays the model's judgment —partanditemare two names, not two spellings. - Types can be proposed as kinds of other types. Ask for it
(
new SinglePassOntologyProposer(model, proposeTypeRelations: true)), or declare a super type (new DeclaredStructure(…) { SuperTypes = ["Equipment"] }), and the proposal carriesTypeRelations:HydraulicPressis a kind ofPress, each a grounded claim citing the records that say so. The ends are type names, not entities — a record may say what a kind of thing is without being a record of one — and are spelled as the names already in use. A declared pair comes back stamped declared, and one that inverts a declaration is left out and reported. Not asked for, the request is the one a call always sent. The prompt asks only for the type relations the records support; which kinds become types at all stays the model's modelling choice. - Declared always wins. Where structure is explicitly declared, the
declaration is the answer. Inference only fills what nothing declared.
A call takes one declaration per subject, so a caller whose records name
several kinds of thing — chunks of a document name companies, people and
products at once — declares each kind, by name alone when that is all it
knows (
new DeclaredStructure(SubjectRef.Create("Organization"), [], [])), and the roles it needs kept apart as typed relations (PartnerOf,CustomerOf). A declaration is neither a filter nor a renaming: a type the model proposes that nothing declares still comes back, as inferred, under the name the model used. Measured on two Korean company-profile documents, three attempts each, declaringOrganization,PersonandProductwith four typed relations took the entity names proposed under more than one type (companyin one attempt,organizationin the next) from 7 of 9 to 0 of 9 in a one-chunk document, and to 0 of 19 in a seven-chunk one that drifted little without it (1 of 18), while kinds nothing declared — locations, services, dates — kept coming. Two earlier wordings of the declaration sentence failed that measurement in opposite regimes: one held document chunks to exactly the declared types, the other, allowing the model its own types only where nothing declared describes an entity, left nothing to propose beyond a form declaration. A measurement, not a promise. Enforced after the model answers, not only asked of it: every proposal carries whether its type was declared (ProposalBasis), and a relation proposed under a declared name whose ends contradict the declaration is left out and reported (RejectionReason.ContradictsDeclaration) rather than returned. One caveat from measurement: handed a partial declaration under an earlier prompt that asked the model to "infer only what nothing declares", it proposed only what was declared and inferred nothing beyond it, so a partial declaration reached fewer competency questions than no declaration at all (the declared-completeness ablation in the test project). The prompt now says a declaration is a floor, not a ceiling; whether the model treats it that way is what the ablation's "inferred reach" column measures — a measurement, not a promise. - Confidence routes, it doesn't decide. Every proposal carries a
confidence score; a caller-defined threshold routes it to auto-apply,
human review, or draft-only. Eyu proposes — it never applies anything.
The routing is code, not a convention left to the caller: a
RoutingPolicyholds the caller's thresholds (per origin — see §C),Routereturns the tier, andTracewraps the proposal in HoneAI'sITracedPrediction<T>provenance stamp so anyIHitlGate-style review flow can consume it. There is no default threshold, on purpose: self-reported confidence is not trustworthy uncalibrated — see design rationale, §D. - No claim without a reason. A response is a structure of
{claim, sources[], path[]}. A claim that can't cite its sources cannot be expressed — this is enforced by the output shape, not by a prompt — and a source must be a record the call was actually given: a response that cites an id it was never shown is refused whole, not passed through — the check can see that an id exists, never that the record says what the claim says, so one invented citation leaves no ground for trusting the others. Every other defect costs only its own element: an entity or relation with a missing field or an out-of-range confidence, every entity under a duplicated id, and a relation whose end is not a surviving entity of the same response are left out, and each appears inOntologyProposal.Rejectionswith aRejectionReason. Nothing is dropped silently — a caller that wants all-or-nothing checksRejections.Count, and a caller measuring the model counts the reasons. ("Grounding" throughout means this citation back to records — not the normalization of a mention to an ontology term id that biomedical extraction tools call ontology grounding; Eyu does no such lookup. See design rationale, §B.)
What Eyu is
- A judgment library. Given structure and records, it proposes what the
entities, relations, and their confidence are — including which records
refer to the same real-world entity (entity resolution is part of
proposing what the entities are, not a separate concern). An entity
cites every record it appears in, and says which of those are it
(
EntityProposal.DenotedBy) — the rest only mention it, the way a work order names its machine. Only the denoting records are a claim that they are one entity. Where the caller declared the field a relation runs through (DeclaredRelation.ViaField), a record holding an entity's name there is held to mention it whatever the model answered: that denotation is withdrawn and reported (OntologyProposal.DemotedDenotations) — unless it is the only thing the record would denote. That's the whole surface. - Storage-agnostic. It has no raw store, no projection target, no query engine of its own.
- Provider-agnostic. Model access is a single injected port; local or hosted inference both work unmodified.
What Eyu is not
- Not a store. It doesn't own raw data, projected tables, or a graph database. Something upstream owns storage; Eyu only judges what's in it.
- Not a permission system. It has no concept of who is allowed to see what — that's a consuming system's job, enforced before or after Eyu is called, never inside it.
- Not a federation layer. It doesn't know how to reach a remote system, retry a query, or merge live records. It receives records; it doesn't fetch them.
- Not an agent, not a UI, not a chat surface. It answers a structural question with a grounded proposal — nothing about how that proposal reaches a person is in scope here.
Ports
| Port | Responsibility |
|---|---|
IStructureSource |
What the caller has already declared — field hints, relations, version |
IRecordSample |
Raw records to infer from, when declaration alone is insufficient |
IOntologyProposer |
The core judgment: entities, relations, confidence, and entity resolution (merging records that denote the same entity) — all from the above. Each entity carries its Name as the records write it, and each proposal a VocabularyOrigin (Innate | Acquired) — see design rationale, §C |
IGroundingContract |
{claim, sources[], path[]} — the shape every answer is expressed in |
IModelClient |
Provider-neutral inference access (local or hosted) |
Each consumer implements IStructureSource/IRecordSample for its own world
— an owned raw store, a declared schema, a federated read — and gets the same
proposal logic back through IOntologyProposer.
Routing is also a value, not a port. A caller builds a
RoutingPolicy from its own
RoutingThresholds (auto-apply / review lower bounds, one pair per
VocabularyOrigin), then calls proposal.Route(policy) for the tier or
proposal.Trace(policy) for the proposal wrapped in a HoneAI
PredictionProvenance (Kind = Llm, the confidence, the claim as rationale, a
one-step Path that is Settled for auto-apply and Escalated for every tier a
machine may not settle, and the
route / origin / basis / cited record ids as annotations). Those annotation keys
are constants on
ProvenanceAnnotations, and the
stamp's value type is string: route, origin and basis are enum names, while the
cited ids arrive as a JSON array of strings — record ids are caller-supplied
and may contain any delimiter, so they are serialized rather than joined. A
consumer reading the trace parses that one value; the rest are read as-is. Eyu
references only
HoneAI.Abstractions —
the zero-dependency contract package — and never implements IHitlGate: opening
a gate, awaiting the reviewer, and applying an approved proposal are the
consumer's, because Eyu never applies anything.
The bundled HttpModelClient speaks
the OpenAI-compatible chat/completions shape; the caller owns the HttpClient's base
address, auth header and timeout. A provider-specific request field the library does not
model — a self-hosted server's thinking control (chat_template_kwargs, reasoning), a
temperature — is passed through an optional extraBody of opaque JSON merged into every
request (the OpenAI SDK's extra_body convention; model and messages stay owned by the
client). When the provider reports token counts, ModelResponse.Usage carries them
(prompt / completion / total, each optional) verbatim, so a caller can budget context or
bill against the server's own numbers.
A thinking model can reason for minutes before the first word of its answer, and a proxy or
gateway in front of the server may close a response whose first byte is that slow (100
seconds is a common limit). stream: true asks for the answer as it is written and joins the
streamed chunks, so bytes flow while the model thinks — new HttpModelClient(httpClient, model, stream: true). It is off by default, as in the OpenAI SDKs.
A failed call is an HttpRequestException, however it fails: a status outside the success
range (the exception carries the status, where the request went and what the server answered),
an error the endpoint reports inside a streamed answer, or a connection that closes before a
streamed answer ends. The client does not retry; whether and how often to is the caller's call.
A retry policy on the HttpClient's handler pipeline covers what fails before the response
arrives, but a streamed call has its response once the headers are in, so a cut in the middle of
the answer surfaces from CompleteAsync after the pipeline is done — retry around the call to
cover that too. RetryingModelClient is that wrapper, opt-in:
var retried = 0;
IModelClient model = new RetryingModelClient(
new HttpModelClient(http, "gpt-4o-mini", stream: true),
retries: 1,
onRetry: retry => retried++); // say the results include retried calls
It retries a dropped connection or stream, an error reported inside the stream, a timeout, and
408 / 429 / 5xx — not a refusal, an empty answer or an answer that does not parse
(RetryingModelClient.IsTransient; pass isTransient to choose otherwise). How many retries, and
an optional delay doubled before each one, are the caller's.
The proposer describes the JSON it expects in the prompt, but a sentence in a prompt does not
stop every model from wrapping its answer in a markdown code fence — some self-hosted
instruction models do so routinely — and a fenced answer is refused as invalid JSON (the
exception carries the text, so the cause is visible). The parser does not strip fences: a
formatting violation it absorbed would stop showing up in measurement. So the proposer also
hands the model client the response's JSON Schema (ModelRequest.ResponseSchema, strict:
every field required, nothing extra), and HttpModelClient sends it as OpenAI structured
output — response_format: {"type": "json_schema", ...} — with no configuration. Its
json_schema form is used rather than the older {"type": "json_object"} because servers do
not all honor the latter: measured against one self-hosted OpenAI-compatible server and
instruction model, json_object was accepted and ignored (three of three answers still
fenced), while the proposer's schema sent this way produced parseable JSON on five of five
calls across a one-chunk and a seven-chunk document — and the same calls with structured
output turned off came back fenced again.
A server that rejects json_schema, or handles it badly, is the caller's to override: a
response_format in extraBody replaces the mapped one, and {"type": "text"} turns
structured output off.
var extraBody = new Dictionary<string, JsonElement>
{
["response_format"] = JsonSerializer.SerializeToElement(new { type = "text" }),
};
var client = new HttpModelClient(httpClient, model, extraBody);
A caller with its own IModelClient maps ResponseSchema to its provider's structured
output the same way, or ignores it and relies on the prompt.
Entity resolution is tuned through a value, not a port: SinglePassOntologyProposer
accepts an optional LinkageOptions record
covering the record-linkage pre-filter's classification thresholds (compared against the
log-likelihood ratio, not the posterior — docs/linkage-benchmark.md), its EM iteration
limits, and how field values are compared — exactly, by Jaro-Winkler, or as words (token Jaccard,
Monge-Elkan), for every field or per field (FieldComparison) — and on one line or in levels:
FieldComparison.JaroWinkler(1.0, 0.92, 0.88) sorts a pair into an exact match, a near miss, a looser
one or a disagreement, and the estimator fits each level its own weight. Every default
reproduces the behavior of passing nothing, so a caller reaches for it only once a live
run shows the defaults classifying that caller's data badly — what each value does, and
what is still unmeasured about them, is in Status.
Asking whether a new record is someone already known, one record at a time, is a different job
from deduplicating a batch, and it needs the parameters fitted elsewhere. A call of one record
against N known entities holds about one true match among N pairs — too little for EM to learn
what a match looks like, so a fit there reads whatever most pairs share as identity. Fit once on the
population instead and keep the result: LinkagePipeline.EstimateNonMatchAgreement measures each
field's u over random pairs of it, m comes from a reviewed sample or a fixed-u fit
(FellegiSunterEstimator.Estimate with fixedNonMatchAgreement — on pairs whose non-matches agree
about as often as random pairs do; pairs picked for looking alike inflate the fitted match prior), and
FieldLinkageParameters.FromNonMatchAgreement puts them together. Then
LinkagePipeline.Analyze(records, knownEntities, parameters) scores each new record against the
known ones without fitting anything; the matches are in LinkageAnalysis.KnownCandidates. A proposal
that compares a few records with known entities on every call takes the same parameters once —
new SinglePassOntologyProposer(model, linkageParameters: parameters) — and scores each call's
pre-filter with them, so every call judges on the population's scale rather than on a fit over its own
handful of pairs.
Without a model, one new record against the people already known:
using Eyu.Core.Linkage;
using Eyu.Core.Records;
// The people already known, each with the record that denotes it. A real population is the whole
// register -- the more of it the measurement sees, the better it knows how often strangers agree.
RawRecord[] population =
[
new("c-1", new Dictionary<string, string?> { ["name"] = "Kim Minji", ["phone"] = "010-1234-5678", ["city"] = "Seoul" }),
new("c-2", new Dictionary<string, string?> { ["name"] = "Lee Junho", ["phone"] = "010-2222-3333", ["city"] = "Busan" }),
new("c-3", new Dictionary<string, string?> { ["name"] = "Park Seoyeon", ["phone"] = "010-4444-5555", ["city"] = "Seoul" }),
new("c-4", new Dictionary<string, string?> { ["name"] = "Kim Minji", ["phone"] = "010-9876-5432", ["city"] = "Daegu" }),
];
KnownEntity[] known = [.. population.Select(r => new KnownEntity(r.Id, r.Fields["name"]!, "Customer", [r]))];
// Fit once, on the population: how often each field agrees between strangers (u), and how often a
// true duplicate still agrees (m -- no default; between 0.90 and 0.95 it decides whether two people
// with one name are told apart). The match prior feeds posteriors only, not the log-likelihood ratio.
var nonMatch = LinkagePipeline.EstimateNonMatchAgreement(population);
var parameters = FieldLinkageParameters.FromNonMatchAgreement(nonMatch, matchAgreement: 0.92, matchPrior: 0.001);
// Then score each new record against the known ones, fitting nothing on it.
RawRecord incoming = new("new-1", new Dictionary<string, string?> { ["name"] = "Kim Minji", ["phone"] = "010-1234-5678", ["city"] = "Seoul" });
var analysis = LinkagePipeline.Analyze([incoming], known, parameters);
// One candidate per known entity the record was compared with and not ruled out.
foreach (var candidate in analysis.KnownCandidates.OrderByDescending(c => c.LogLikelihoodRatio))
{
Console.WriteLine($"{candidate.RecordId} ~ {candidate.KnownEntityKey}: {candidate.Classification} (LLR {candidate.LogLikelihoodRatio:0.0})");
}
This prints new-1 ~ c-1: Match, and GrayZone for c-3 and c-4, each of which shares one field with the
new record; c-2, which shares none, classifies as NonMatch and is left out.
Read Match as the answer and GrayZone as a pair for someone to review: with the default thresholds
(LinkageOptions) one agreeing field out of three or four lands there, and the number of such pairs grows
with the population and with how common a value is — on a few records the measurement cannot yet tell a
rare name from a common one, so measure u over the whole register. KnownCandidates holds only
the comparisons with known entities; pairs among the call's own records, if it is given several, are in
PairLinkages and the clusters, as in a batch.
One u per field weighs every agreement on it alike, so agreeing on a surname a fifth of the population
holds counts as much as agreeing on one nobody else has. LinkagePipeline.MeasureValueFrequencies(population, ["surname", "given_name"]) measures how common each value is; set it on the parameters
(parameters with { ValueFrequencies = … }) and an exact agreement on a listed value is scored against that
value's share instead. Name the fields whose values repeat unevenly — names, towns — not numbers or dates.
Measured on Febrl's dataset3 with only names and a town to go on, 1,500 people known and 300 strangers
scored, it moved the strangers wrongly classified Match from 7 to 5 — and from 90 to 85 with surnames
redrawn as concentrated as in Korea, where without it they reach 30% at that size
(linkage-benchmark.md). The effect is
small and not uniform. Of what is left with Korean surnames, two in three are strangers with the same full
name as the known person, which names and a town cannot tell apart — add a field that can (a birth date, a
phone number). A
proposal puts its gray candidates against known entities to the model under the same
MaxGrayZonePairsInPrompt budget as its own pairs, strongest first.
Without blocking every pair is compared — the square of a batch, or the whole known population for
each record. LinkageOptions.BlockingRules narrows that: each rule is a set of fields, a pair is
compared when it agrees exactly on every field of some rule, and rules are alternatives, so one rule
can catch what another misses (a misspelt name, but the same birth date and school). A pair no rule
reaches is not judged different, only not compared.
Where the caller knows which field keys a subject's records — a table's key, a register's number —
it declares it (new DeclaredStructure(…) { KeyField = "wo_no" }). Two records of that subject with
different keys are then different things: the pre-filter neither compares nor fits on the pair, and
the model is told so. A small batch needs this most — with no true duplicate to learn a match from, a
fit can read values two rows happen to share (a quantity, a note) as identity. Records with the same
key are still compared, so a duplicate entry is still found.
Gray-zone pairs grow with the square of the batch, so they — not the records — are what fills a
prompt first: measured, 47 records produced 542 pairs, more lines than the records themselves, and
104 records produced 2,187 pairs and a prompt past a 131,072-token context.
LinkageOptions.MaxGrayZonePairsInPrompt (default 200) caps how many are put to the model; past
it the pairs with the strongest prior toward the same entity are kept, and the proposal says how
many were left out (OntologyProposal.Linkage.GrayZonePairsOmitted). A pair left out is not judged
different — the model still sees both records — it is only not singled out. Size a batch by its
records and their length plus that cap.
Every proposal from SinglePassOntologyProposer also carries what the pre-filter contributed
(OntologyProposal.Linkage): its full result for the call (Analysis) and the confirmed groups
the answer spread over more than one entity anyway (SplitClusters). The pre-filter compares every
field two records share, so documents that copy the same values from a record they refer to — the
same machine's number and location on every inspection — agree, and read as one entity; a model
that sees they are different documents will not merge them. That list is where the disagreement
shows, for a caller to settle from the records.
One default is a premise rather than a tuning value. The pre-filter assumes a record
is one mention of one entity — a row, a form submission, a directory entry — so that
two records agreeing on their fields is evidence they denote the same thing. A document
fragment is not that: a text chunk with a title and a path names many entities and
denotes none, and there the premise inverts — measured, chunks of one document agree on
their metadata, the estimator reads the agreement as identity, and the whole document is
pre-linked as one entity before the model sees it. Records of that kind still propose and
ground correctly (a chunk id is a fine source id); pass
LinkageOptions with RecordsDenoteEntities: false and no pair is compared, every
record stays its own singleton, and the prompt carries no pre-linked groups or gray-zone
pairs. With the pre-filter off there is no linkage prior, so nothing adjusts the model's
self-reported confidence up or down — it is carried through as given. The cited sources then
no longer identify an entity either (a chunk cites many), and no chunk denotes one —
EntityProposal.DenotedBy is always empty here, whatever the model answered — so
EntityProposal.Name — the entity as the records write it — is what a caller links and stores by; EntityId only ties
relations to entities inside one proposal and must never be persisted as an identity. Keep one document
per batch, and mind that every record is rendered into the
prompt in full — the batch size is bounded by the model's context, not by Eyu.
Exporting as RDF/OWL
Eyu.Rdf writes an OntologyProposal as an OWL ontology in RDF Turtle, so a proposal can be
opened in an OWL editor, loaded into a triple store or checked by a SHACL validator:
var turtle = OntologyTurtle.ToTurtle(proposal, new RdfExportOptions(new Uri("https://example.org/plant#")));
Each distinct entity type becomes an owl:Class, each distinct relation name an
owl:ObjectProperty, each entity an owl:NamedIndividual and each relation an assertion between
two of them. The claim, the cited records, which of them denote an individual (eyu:denotedBy), the
confidence and whether a type was declared travel as annotations — on a relation, as an OWL axiom annotation (owl:Axiom), the form an OWL editor attaches
to the assertion itself. Acquired terms are minted
under the namespace you pass; innate ones are Eyu's own terms, and neither is aligned to an outside
vocabulary. An acquired term's IRI comes from its name compared the way Eyu compares names — case,
separators and Unicode form ignored (Hangul decomposed or precomposed, Latin full-width or not) — with a class's first letter upper-cased and a property's lower-cased, so
Work Order and WorkOrder from two exports are one class :Workorder, and maintains and
Maintains one property :maintains; the spelling each export used is kept as the term's
rdfs:label. Names, labels and claims are written in Unicode NFC; record ids and field names exactly as
given. Rejections are not written. The package depends on nothing beyond Eyu.Core.
An individual's IRI never comes from EntityId, which the model picks afresh on every call. It is
derived from the entity's name and type, compared ignoring case, separators and Unicode form, together with the
records that denote it when any does — the records alone are not enough, since a model reading one
row that reports an event says the row denotes the aircraft, the part and the event alike. The key is
hashed under entity/ in your namespace, so two exports of the same records name one entity alike
and a triple store merging them merges its individuals. The IRI holds only as long as its inputs do:
a renamed entity, a type named differently beyond case and separators (a declared vocabulary holds
types still) or a different set of denoting records is a different IRI, and two different things with one name, type and set of
denoting records share one — within one proposal too, where a model reading a document chunk by chunk
proposes the same company once per chunk: those entities are one individual carrying every claim.
OntologyTurtle.IndividualIris returns the IRI each entity is written under, for linking your own
triples to them. An entity matched to a known entity (KnownEntityKey) is written under that entity
instead: a key that is an absolute IRI is the IRI — pass the IRI an earlier export gave the entity and
a match keeps it, however this call named it — and any other key is hashed under entity/known/.
Merge candidates are not written: a candidate is not owl:sameAs, and joining it is the caller's call.
A type relation is written as rdfs:subClassOf between the two classes — an end no entity carries
becomes a class of its own — with its claim, cited records, confidence and basis as an axiom
annotation, like a relation's.
The ontology names the rule its IRIs were minted under — <ontology> eyu:iriRule "0.5.0", the
release that introduced the rule. The value changes only in a release that changes how a class,
property or individual IRI is minted, and says so in its changelog. Individual IRIs survive such a
change and term IRIs may not, so a store that keeps exports from two rules types one individual into
both the old class and the new one. Load each export into its own named graph and eyu:iriRule
tells which graphs an older rule wrote — drop those and re-export. (Every export names the same
ontology node, so merged into one default graph the annotations merge too and no longer say which
triple came from where.) An export without it was written by 0.5.0, under the rule "0.5.0" names
but before the annotation existed, or by 0.4.0 or earlier, whose rules differ (see the changelog) —
so a graph without it is not by that alone one to drop.
A store that merged older exports into its default graph cannot drop them that way. For it,
new RdfExportOptions(baseIri) { BridgeRetiredIris = true } also writes each acquired class and
property under the IRI the previous rule minted for its spelling (:Work%20Order), marked
owl:deprecated true and pointing at the current term with dcterms:isReplacedBy (:Workorder) — the
way published ontologies retire a renamed term, so the store can tell the retired term from the live
one. Only the previous rule is bridged, and only from the spellings the export itself uses.
Further reading
Status — what each measurement establishes and what it does not.
Design rationale — why judgment requires structure first, what kind of thing a proposed ontology is, and why proposals carry an innate/acquired origin tag, each with a confidence grade on how well-anchored the reasoning is.
License
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- HoneAI.Abstractions (>= 0.10.0)
NuGet packages (2)
Showing the top 2 NuGet packages that depend on Eyu.Core:
| Package | Downloads |
|---|---|
|
Eyu.Formbase
Adapts a Formbase IFieldHintSource to Eyu's IStructureSource -- declared field hints flow in as Eyu's source-agnostic declared structure, unchanged in meaning. |
|
|
Eyu.Rdf
Writes an Eyu OntologyProposal as an OWL ontology in RDF Turtle -- proposed types as classes, relation names as object properties, entities as named individuals -- with every claim's sources, confidence and basis carried as annotations. No dependency beyond Eyu.Core. |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 0.23.0 | 111 | 10/23/2026 |
| 0.22.0 | 82 | 10/23/2026 |
| 0.21.0 | 113 | 10/22/2026 |
| 0.20.0 | 94 | 10/22/2026 |
| 0.19.0 | 136 | 10/21/2026 |
| 0.18.0 | 106 | 10/21/2026 |
| 0.17.0 | 108 | 10/20/2026 |
| 0.16.0 | 113 | 10/20/2026 |
| 0.15.0 | 105 | 10/20/2026 |
| 0.14.0 | 116 | 10/20/2026 |
| 0.13.0 | 103 | 10/20/2026 |
| 0.12.0 | 111 | 10/20/2026 |
| 0.11.0 | 108 | 10/20/2026 |
| 0.10.0 | 127 | 10/19/2026 |
| 0.9.0 | 149 | 10/5/2026 |
| 0.8.0 | 128 | 10/1/2026 |
| 0.7.0 | 113 | 10/1/2026 |
| 0.6.0 | 144 | 9/29/2026 |
| 0.5.0 | 131 | 9/24/2026 |
| 0.4.0 | 114 | 9/24/2026 |
See CHANGELOG.md