cadrumo.adapters.outbound.llm.column_role_mapping module

Semantic column-role mapping: observed headers to the closed FieldRole set.

A delimited export is already a text representation, so reading one is not a transcription problem but a naming problem: the file states what each column holds, in whichever vocabulary its producer chose. A Spanish libro registro names its taxable base base_imponible; a bank export names its booked date fecha_operacion; an ERP names neither. Every field the product needs is present in all three, and not one of the names matches an importer column token – which is why an exact-header importer refuses the whole file.

This module establishes what the columns MEAN, once per file, and nothing else. It is a selection over a closed allow-list, not a generation task: the model is handed the observed headers and the enumerated FieldRole members and chooses among them. It never sees, copies, transforms or emits a cell value – deterministic code does that downstream, under the mapping this module produces. That split is the tabular anti-fabrication guarantee: a model that cannot touch a value cannot invent one.

Never refuse whole. A header no role fits becomes UNMAPPED and is reported on ColumnRoleProposal.unmapped_columns; the file still imports without that column. The same holds for every way a reply can be wrong about a column: a role token outside the allow-list, a role a previous column already claimed, a claim about a column the table does not have. Each is recorded on its own field of the proposal and costs exactly the column it concerns. Only a reply that is not parseable at all raises, because there is then no proposal to report.

Consuming this from the tabular lane. The proposal carries positional roles, which is precisely the shape adapters.inbound.financial.ColumnRoleMapping takes. The wiring belongs on the consumer’s side of the boundary – this package sits beside cadrumo.adapters in the layering contract rather than beneath it, so the import runs consumer-to-here and never the reverse:

proposal = SemanticColumnRoleMapper().map(table.headers)
mapping = ColumnRoleMapping(roles=proposal.roles)

Accuracy is not claimed here. This module is gated on structure, refusal behaviour and determinism; how often the mapping is right is a measured figure owned by the measurement lane.

COLUMN_ROLE_MAPPING_PROMPT_ID

Identifier under which this capability’s calls are recorded in LLM usage and run telemetry, so a mapping call is attributable separately from a read.

class ObservedColumn(**data)[source]

Bases: BaseModel

One column of the source table, addressed by position and printed name.

Parameters:

data (Any)

column_index: int
header: str
class RejectedRoleProposal(**data)[source]

Bases: BaseModel

A claim naming a role token that is not a FieldRole member.

The allow-list refusal. The token is kept verbatim so an operator or a measurement run can see what was actually proposed rather than a redacted “invalid role”; the column itself falls back to UNMAPPED.

Parameters:

data (Any)

column_index: int
header: str
proposed_role: str
class DiscardedDuplicateClaim(**data)[source]

Bases: BaseModel

A claim that lost to an earlier one and was therefore not applied.

Covers both shapes of double claim: a second column claiming a role an earlier column already holds, and a second claim about a column already decided. First claim wins in both, deterministically, and the loser is reported here rather than silently dropped.

Parameters:

data (Any)

column_index: int
header: str
role: FieldRole
kept_column_index: int
class UnknownColumnClaim(**data)[source]

Bases: BaseModel

A claim about a column position the table does not carry.

Parameters:

data (Any)

column_index: int
proposed_role: str
class ColumnRoleProposal(**data)[source]

Bases: BaseModel

What each column of one table means, plus everything that was not established.

roles is positional and always carries exactly one entry per observed column, so it can be handed to a positional consumer without further checking. Every column the mapping did not establish holds UNMAPPED there AND appears on unmapped_columns, whichever way it came to be unmapped – the remaining fields say why, and are the operator-reportable record of what the mapping call got wrong.

Parameters:

data (Any)

roles: tuple[FieldRole, ...]
unmapped_columns: tuple[ObservedColumn, ...]
rejected_role_proposals: tuple[RejectedRoleProposal, ...]
discarded_duplicate_claims: tuple[DiscardedDuplicateClaim, ...]
unknown_column_claims: tuple[UnknownColumnClaim, ...]
class ProposedColumnRole(**data)[source]

Bases: BaseModel

One column-to-role claim as the model stated it.

role is typed str rather than FieldRole on purpose. Validating it as the enum here would make an out-of-allow-list token a whole-reply validation failure – refusing the file over one bad column, which is the exact defect this lane exists to remove. Keeping it a string moves the allow-list check into parse_column_role_mapping_response(), where it costs one column and is reported.

Parameters:

data (Any)

column_index: int
role: str
class ColumnRoleMappingReply(**data)[source]

Bases: BaseModel

The whole reply shape the mapping prompt asks for.

Closed keys: a reply carrying anything beyond assignments is not the reply that was asked for, and accepting stray keys is how an injected instruction rides in alongside a well-formed answer.

Parameters:

data (Any)

assignments: tuple[ProposedColumnRole, ...]
permitted_column_roles()[source]

Return every role a column may be assigned, in declaration order.

Derived from FieldRole itself rather than from a list held here, so a role added to the enum is offered to the model, accepted by the parser and rejected-if-absent by the allow-list without one edit in this module. A hand-kept copy would drift the day the enum grows – and the enum is expected to grow.

Return type:

tuple[FieldRole, ...]

build_column_role_mapping_prompt(headers)[source]

Build the instruction the model receives for one table’s headers.

Separate from the transport so the exact instruction is assertable without dispatching a request – and so the enumerated allow-list can be proved to come from the enum rather than from a template literal.

The instruction is written for the smallest capable model: one task, an explicit closed vocabulary, a stated escape hatch (unmapped) so the model never has to force a fit, and one literal reply shape. No cell value appears anywhere in it.

Parameters:

headers (Sequence[str]) – The header cells, in column order, exactly as the file printed them.

Returns:

the task, the permitted roles, the observed columns, and the required reply shape.

Return type:

The full prompt

Raises:

LLMValidationError – When headers is empty, which would ask a model to label nothing and invite it to invent columns.

parse_column_role_mapping_response(text, headers)[source]

Turn one model reply into a proposal over headers.

Every claim is checked against the allow-list derived from FieldRole and against the table’s actual columns. A claim that fails either check is recorded on the proposal and the column it concerns stays UNMAPPED; the remaining columns are unaffected. First claim wins on any collision, so the same reply always yields the same proposal.

Parameters:
  • text (str) – The model’s reply, in whatever wrapping it arrived.

  • headers (Sequence[str]) – The header cells the reply was asked about, in column order.

Return type:

ColumnRoleProposal

Returns:

The positional mapping plus the full record of what was not established.

Raises:

LLMValidationError – When headers is empty, when the reply carries no JSON object, or when that object is not the reply shape that was asked for. These leave nothing to report a column against; every per-column problem is reported instead of raised.

class SemanticColumnRoleMapper(*, model=None, provider=None, client=None, settings=None)[source]

Bases: object

Establish one table’s column roles with a language model, once per file.

Binds to LLMClient and nothing lower, so which engine answers – an on-host runtime or a gated hosted one – is configuration rather than a fact this class holds.

The model comes from the role, never from the general default. Left to itself the client would answer a mapping call on cadrumo_llm_model – a frontier hosted model – for a task that is selecting among a handful of short strings. So the model is resolved through select_model_for_role() against COLUMN_ROLE_MAPPING, which names the WEAKEST catalogued candidate clearing the capability, licence and headroom bars on this machine. Re-pointing that role in the catalogue re-points this lane with no edit here.

A role-resolved model is an on-host runtime id, so it is paired with the LOCAL provider: the pairing is the point, and sending an Ollama id to a hosted API would fail at the transport. A caller that pins model explicitly owns that pairing and gets no provider override; a caller that pins provider gets exactly what it asked for. That is the seam the measurement lane runs its gated cloud engine through, and it is the only way off-host.

Parameters:
  • model (str | None) – Optional runtime id, honoured over the role resolution. Passed to selection as the operator override, so a licence-barred or uncatalogued choice is still honoured – and still advised on.

  • provider (LLMProvider | None) – Optional provider override. None pairs a role-resolved model with LOCAL, and leaves an explicitly pinned model on whichever provider is configured.

  • client (LLMClient | None) – Injected client (dependency injection for callers holding one); default-constructed against the resolved settings otherwise.

  • settings (Settings | None) – Injected settings; defaults to load_settings().

property decided_by: str

Provenance stamp naming the transport and model a mapping was reached with.

This stamp previously named no transport at all, which had two costs. It could not say whether a mapping had left the host – and this is the one reader besides the extractors that accepts a provider, so it is the one that can. And its reader name parsed AS a transport, so a survey classifying artefacts by that segment read column where it expected a transport.

map(headers)[source]

Establish the roles of headers, one call for the whole file.

Parameters:

headers (Sequence[str]) – The header cells, in column order, exactly as the file printed them. No cell value is passed, and none is accepted.

Return type:

ColumnRoleProposal

Returns:

The positional mapping plus every column that was not established.

Raises:

LLMValidationError – When headers is empty or the reply carries no usable object.

map_column_roles(headers, *, model=None, provider=None, settings=None)[source]

Convenience wrapper: build a SemanticColumnRoleMapper and map once.

Parameters:
  • headers (Sequence[str]) – The header cells, in column order.

  • model (str | None) – Optional runtime id, honoured over the role resolution.

  • provider (LLMProvider | None) – Optional provider override.

  • settings (Settings | None) – Optional resolved settings override.

Return type:

ColumnRoleProposal

Returns:

The positional mapping plus every column that was not established.

resolve_column_roles(table)[source]

Resolve a normalized table for the inward column-role port.

The concrete mapper stays owned by this outbound adapter. Composition roots bind this function to the application port, while inbound financial providers consume only the inward contract. Missing optional capability remains a typed refusal; an installed mapper that cannot establish roles declines the table.

Return type:

ColumnRoleMappingPort | None

Parameters:

table (NormalizedTable)