tejtm 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
tejtm/__init__.py ADDED
@@ -0,0 +1,30 @@
1
+ from tejtm.coercion import coerce_record, coerce_value
2
+ from tejtm.engine import MappingEngine
3
+ from tejtm.models import (
4
+ DEFAULT_WEIGHTS,
5
+ FieldMapping,
6
+ MappingConfig,
7
+ MappingResult,
8
+ ScorerResult,
9
+ SourceColumn,
10
+ SourceProfile,
11
+ TargetField,
12
+ TargetSchema,
13
+ TypeGuess,
14
+ )
15
+
16
+ __all__ = [
17
+ "coerce_record",
18
+ "coerce_value",
19
+ "DEFAULT_WEIGHTS",
20
+ "FieldMapping",
21
+ "MappingConfig",
22
+ "MappingEngine",
23
+ "MappingResult",
24
+ "ScorerResult",
25
+ "SourceColumn",
26
+ "SourceProfile",
27
+ "TargetField",
28
+ "TargetSchema",
29
+ "TypeGuess",
30
+ ]
tejtm/assignment.py ADDED
@@ -0,0 +1,65 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ def assign_one_to_one(
5
+ scores: list[list[float]],
6
+ min_confidence: float,
7
+ ) -> list[tuple[int, int] | None]:
8
+ """Return (row, col) per row index, or None when unassigned."""
9
+ if not scores:
10
+ return []
11
+ n_rows = len(scores)
12
+ n_cols = len(scores[0]) if scores else 0
13
+ if n_cols == 0:
14
+ return [None] * n_rows
15
+
16
+ pairs = _hungarian_maximize(scores)
17
+ result: list[tuple[int, int] | None] = [None] * n_rows
18
+ for row, col in pairs:
19
+ if scores[row][col] >= min_confidence:
20
+ result[row] = (row, col)
21
+ return result
22
+
23
+
24
+ def _hungarian_maximize(scores: list[list[float]]) -> list[tuple[int, int]]:
25
+ n_rows = len(scores)
26
+ n_cols = len(scores[0])
27
+ n = max(n_rows, n_cols)
28
+ cost = [[1.0 - scores[i][j] if i < n_rows and j < n_cols else 1.0 for j in range(n)] for i in range(n)]
29
+ assignment = _linear_sum_assignment(cost)
30
+ out: list[tuple[int, int]] = []
31
+ for i, j in assignment:
32
+ if i < n_rows and j < n_cols:
33
+ out.append((i, j))
34
+ return out
35
+
36
+
37
+ def _linear_sum_assignment(cost: list[list[float]]) -> list[tuple[int, int]]:
38
+ try:
39
+ from scipy.optimize import linear_sum_assignment # type: ignore import-not-found
40
+
41
+ import numpy as np
42
+
43
+ row_ind, col_ind = linear_sum_assignment(np.array(cost))
44
+ return [(int(r), int(c)) for r, c in zip(row_ind, col_ind, strict=True)]
45
+ except ImportError:
46
+ return _greedy_maximize(cost)
47
+
48
+
49
+ def _greedy_maximize(cost: list[list[float]]) -> list[tuple[int, int]]:
50
+ n = len(cost)
51
+ candidates: list[tuple[float, int, int]] = []
52
+ for i in range(n):
53
+ for j in range(n):
54
+ candidates.append((cost[i][j], i, j))
55
+ candidates.sort(key=lambda t: t[0])
56
+ used_rows: set[int] = set()
57
+ used_cols: set[int] = set()
58
+ pairs: list[tuple[int, int]] = []
59
+ for _, i, j in candidates:
60
+ if i in used_rows or j in used_cols:
61
+ continue
62
+ used_rows.add(i)
63
+ used_cols.add(j)
64
+ pairs.append((i, j))
65
+ return pairs
tejtm/coercion.py ADDED
@@ -0,0 +1,50 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import date, datetime
4
+ from typing import Any, Mapping
5
+
6
+ from tejtm.typing.detectors import detect_semantic_type
7
+
8
+
9
+ def coerce_value(value: Any, semantic_type: str) -> Any:
10
+ if value is None or value == "":
11
+ return None
12
+ text = str(value).strip()
13
+ if semantic_type == "boolean":
14
+ low = text.casefold()
15
+ if low in ("true", "yes", "y", "1"):
16
+ return True
17
+ if low in ("false", "no", "n", "0"):
18
+ return False
19
+ return text
20
+ if semantic_type == "integer":
21
+ return int(text.replace(",", ""))
22
+ if semantic_type == "number" or semantic_type == "currency":
23
+ return float(text.replace(",", "").replace("$", "").replace("€", "").replace("£", ""))
24
+ if semantic_type == "date":
25
+ return date.fromisoformat(text[:10])
26
+ if semantic_type == "datetime":
27
+ return datetime.fromisoformat(text.replace("Z", "+00:00")[:19])
28
+ return text
29
+
30
+
31
+ def coerce_record(record: Mapping[str, Any], field_types: Mapping[str, str]) -> dict[str, Any]:
32
+ out: dict[str, Any] = {}
33
+ for key, value in record.items():
34
+ stype = field_types.get(key)
35
+ if stype is None:
36
+ out[key] = value
37
+ continue
38
+ try:
39
+ out[key] = coerce_value(value, stype)
40
+ except (ValueError, TypeError):
41
+ out[key] = value
42
+ return out
43
+
44
+
45
+ def infer_field_types_from_samples(
46
+ records: list[Mapping[str, Any]],
47
+ column: str,
48
+ ) -> str:
49
+ samples = [str(r.get(column, "")) for r in records if r.get(column) is not None]
50
+ return detect_semantic_type(samples).label
tejtm/engine.py ADDED
@@ -0,0 +1,116 @@
1
+ from __future__ import annotations
2
+
3
+ from tejtm.assignment import assign_one_to_one
4
+ from tejtm.models import (
5
+ FieldMapping,
6
+ MappingConfig,
7
+ MappingResult,
8
+ SourceColumn,
9
+ SourceProfile,
10
+ TargetField,
11
+ TargetSchema,
12
+ )
13
+ from tejtm.scorers import DEFAULT_SCORERS
14
+ from tejtm.scorers.base import Scorer, ScoringContext, aggregate_scores
15
+ from tejtm.scorers.header_embedding import HeaderEmbeddingScorer, _get_embedder
16
+ from tejtm.typing.detectors import detect_semantic_type
17
+
18
+
19
+ class MappingEngine:
20
+ def __init__(self, scorers: tuple[Scorer, ...] | None = None) -> None:
21
+ self._scorers = scorers if scorers is not None else DEFAULT_SCORERS
22
+
23
+ def propose_mappings(
24
+ self,
25
+ source: SourceProfile,
26
+ target: TargetSchema,
27
+ *,
28
+ config: MappingConfig | None = None,
29
+ ) -> MappingResult:
30
+ cfg = config or MappingConfig()
31
+ column_types = {col.name: detect_semantic_type(col.samples) for col in source.columns}
32
+ field_embeddings: dict[str, tuple[float, ...]] | None = {} if _get_embedder() else None
33
+ context = ScoringContext(
34
+ config=cfg,
35
+ column_types=column_types,
36
+ field_embeddings=field_embeddings,
37
+ )
38
+
39
+ fields = list(target.fields)
40
+ columns = list(source.columns)
41
+ field_ids = [f.id for f in fields]
42
+
43
+ override_mappings: list[FieldMapping] = []
44
+ override_sources: set[str] = set()
45
+ override_targets: set[str] = set()
46
+ for src_name, tgt_id in cfg.overrides.items():
47
+ if any(c.name == src_name for c in columns) and tgt_id in field_ids:
48
+ override_mappings.append(
49
+ FieldMapping(
50
+ source_column=src_name,
51
+ target_field=tgt_id,
52
+ confidence=1.0,
53
+ breakdown=(),
54
+ )
55
+ )
56
+ override_sources.add(src_name)
57
+ override_targets.add(tgt_id)
58
+
59
+ score_matrix: list[list[float]] = []
60
+ breakdowns: list[list[tuple]] = []
61
+ for col in columns:
62
+ row_scores: list[float] = []
63
+ row_breakdowns: list[tuple] = []
64
+ for fld in fields:
65
+ parts = self._score_pair(col, fld, context)
66
+ conf = aggregate_scores(parts)
67
+ row_scores.append(conf)
68
+ row_breakdowns.append(tuple(parts))
69
+ score_matrix.append(row_scores)
70
+ breakdowns.append(row_breakdowns)
71
+
72
+ assignments = assign_one_to_one(score_matrix, cfg.min_confidence)
73
+ mappings: list[FieldMapping] = list(override_mappings)
74
+ mapped_sources = set(override_sources)
75
+ mapped_targets = set(override_targets)
76
+
77
+ for row_idx, pair in enumerate(assignments):
78
+ if pair is None:
79
+ continue
80
+ col = columns[row_idx]
81
+ if col.name in mapped_sources:
82
+ continue
83
+ _, col_idx = pair
84
+ fld = fields[col_idx]
85
+ if fld.id in mapped_targets:
86
+ continue
87
+ conf = score_matrix[row_idx][col_idx]
88
+ mappings.append(
89
+ FieldMapping(
90
+ source_column=col.name,
91
+ target_field=fld.id,
92
+ confidence=conf,
93
+ breakdown=breakdowns[row_idx][col_idx],
94
+ )
95
+ )
96
+ mapped_sources.add(col.name)
97
+ mapped_targets.add(fld.id)
98
+
99
+ unmapped = tuple(c.name for c in columns if c.name not in mapped_sources)
100
+ unmatched = tuple(f.id for f in fields if f.id not in mapped_targets)
101
+ mappings.sort(key=lambda m: (-m.confidence, m.source_column))
102
+ return MappingResult(
103
+ mappings=tuple(mappings),
104
+ unmapped_columns=unmapped,
105
+ unmatched_fields=unmatched,
106
+ )
107
+
108
+ def _score_pair(self, column: SourceColumn, field: TargetField, context: ScoringContext) -> list:
109
+ parts = []
110
+ for scorer in self._scorers:
111
+ if isinstance(scorer, HeaderEmbeddingScorer) and context.field_embeddings is None:
112
+ continue
113
+ result = scorer.score(column, field, context)
114
+ if result is not None:
115
+ parts.append(result)
116
+ return parts
tejtm/models.py ADDED
@@ -0,0 +1,138 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any, Mapping, Sequence
5
+
6
+
7
+ @dataclass(frozen=True, slots=True)
8
+ class SourceColumn:
9
+ name: str
10
+ samples: Sequence[str] = ()
11
+
12
+
13
+ @dataclass(frozen=True, slots=True)
14
+ class SourceProfile:
15
+ columns: Sequence[SourceColumn]
16
+
17
+ def __post_init__(self) -> None:
18
+ names = [c.name for c in self.columns]
19
+ if len(names) != len(set(names)):
20
+ raise ValueError("SourceProfile column names must be unique")
21
+
22
+
23
+ @dataclass(frozen=True, slots=True)
24
+ class TargetField:
25
+ id: str
26
+ labels: Sequence[str] = ()
27
+ aliases: Sequence[str] = ()
28
+ expected_semantic_type: str | None = None
29
+ description: str | None = None
30
+ exemplar_samples: Sequence[str] = ()
31
+
32
+
33
+ @dataclass(frozen=True, slots=True)
34
+ class TargetSchema:
35
+ fields: Sequence[TargetField]
36
+
37
+ def __post_init__(self) -> None:
38
+ ids = [f.id for f in self.fields]
39
+ if len(ids) != len(set(ids)):
40
+ raise ValueError("TargetSchema field ids must be unique")
41
+
42
+
43
+ @dataclass(frozen=True, slots=True)
44
+ class TypeGuess:
45
+ label: str
46
+ confidence: float
47
+ evidence: str = ""
48
+
49
+
50
+ @dataclass(frozen=True, slots=True)
51
+ class ScorerResult:
52
+ scorer: str
53
+ score: float
54
+ weight: float
55
+ reason: str = ""
56
+
57
+
58
+ @dataclass(frozen=True, slots=True)
59
+ class FieldMapping:
60
+ source_column: str
61
+ target_field: str
62
+ confidence: float
63
+ breakdown: tuple[ScorerResult, ...] = ()
64
+
65
+
66
+ @dataclass(frozen=True, slots=True)
67
+ class MappingResult:
68
+ mappings: tuple[FieldMapping, ...]
69
+ unmapped_columns: tuple[str, ...]
70
+ unmatched_fields: tuple[str, ...]
71
+
72
+ def to_dict(self) -> dict[str, Any]:
73
+ return {
74
+ "mappings": [
75
+ {
76
+ "source_column": m.source_column,
77
+ "target_field": m.target_field,
78
+ "confidence": m.confidence,
79
+ "breakdown": [
80
+ {
81
+ "scorer": b.scorer,
82
+ "score": b.score,
83
+ "weight": b.weight,
84
+ "reason": b.reason,
85
+ }
86
+ for b in m.breakdown
87
+ ],
88
+ }
89
+ for m in self.mappings
90
+ ],
91
+ "unmapped_columns": list(self.unmapped_columns),
92
+ "unmatched_fields": list(self.unmatched_fields),
93
+ }
94
+
95
+ @classmethod
96
+ def from_dict(cls, data: Mapping[str, Any]) -> MappingResult:
97
+ mappings = tuple(
98
+ FieldMapping(
99
+ source_column=m["source_column"],
100
+ target_field=m["target_field"],
101
+ confidence=float(m["confidence"]),
102
+ breakdown=tuple(
103
+ ScorerResult(
104
+ scorer=b["scorer"],
105
+ score=float(b["score"]),
106
+ weight=float(b["weight"]),
107
+ reason=b.get("reason", ""),
108
+ )
109
+ for b in m.get("breakdown", [])
110
+ ),
111
+ )
112
+ for m in data.get("mappings", [])
113
+ )
114
+ return cls(
115
+ mappings=mappings,
116
+ unmapped_columns=tuple(data.get("unmapped_columns", [])),
117
+ unmatched_fields=tuple(data.get("unmatched_fields", [])),
118
+ )
119
+
120
+
121
+ @dataclass(frozen=True, slots=True)
122
+ class MappingConfig:
123
+ weights: Mapping[str, float] = field(default_factory=dict)
124
+ min_confidence: float = 0.55
125
+ intent_margin: float = 0.08
126
+ overrides: Mapping[str, str] = field(default_factory=dict)
127
+
128
+ def weight(self, scorer: str, default: float) -> float:
129
+ return self.weights.get(scorer, default)
130
+
131
+
132
+ DEFAULT_WEIGHTS: dict[str, float] = {
133
+ "exact_name": 1.0,
134
+ "fuzzy_name": 0.45,
135
+ "semantic_type": 0.25,
136
+ "header_embedding": 0.20,
137
+ "value_intent": 0.30,
138
+ }
tejtm/normalize.py ADDED
@@ -0,0 +1,44 @@
1
+ import re
2
+ import unicodedata
3
+ from typing import Sequence
4
+
5
+ _CAMEL_BOUNDARY = re.compile(r"(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])")
6
+ _NON_ALNUM = re.compile(r"[^\w\s]+", re.UNICODE)
7
+ _WS = re.compile(r"\s+")
8
+
9
+
10
+ def normalize_identifier(text: str) -> str:
11
+ if not text:
12
+ return ""
13
+ text = unicodedata.normalize("NFKC", text)
14
+ text = text.strip().casefold()
15
+ text = _NON_ALNUM.sub(" ", text)
16
+ text = _WS.sub(" ", text).strip()
17
+ return text
18
+
19
+
20
+ def tokenize_identifier(text: str) -> tuple[str, ...]:
21
+ if not text:
22
+ return ()
23
+ raw = unicodedata.normalize("NFKC", text.strip())
24
+ raw = _NON_ALNUM.sub(" ", raw)
25
+ parts: list[str] = []
26
+ for chunk in _WS.split(raw):
27
+ if not chunk:
28
+ continue
29
+ for piece in _CAMEL_BOUNDARY.sub(" ", chunk.replace("_", " ")).split():
30
+ piece = normalize_identifier(piece)
31
+ if piece:
32
+ parts.append(piece)
33
+ return tuple(parts)
34
+
35
+
36
+ def candidate_names(field_id: str, labels: Sequence[str], aliases: Sequence[str]) -> tuple[str, ...]:
37
+ seen: set[str] = set()
38
+ out: list[str] = []
39
+ for raw in (field_id, *labels, *aliases):
40
+ norm = normalize_identifier(raw)
41
+ if norm and norm not in seen:
42
+ seen.add(norm)
43
+ out.append(norm)
44
+ return tuple(out)
@@ -0,0 +1,106 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Mapping, Sequence, TypeVar
5
+
6
+ from tejtm.coercion import coerce_record
7
+ from tejtm.engine import MappingEngine
8
+ from tejtm.models import MappingConfig, MappingResult, SourceColumn, SourceProfile, TargetField, TargetSchema
9
+
10
+ T = TypeVar("T")
11
+
12
+
13
+ @dataclass(frozen=True, slots=True)
14
+ class ImportResult:
15
+ valid_rows: list[Any]
16
+ errors: list[dict[str, Any]]
17
+ mapping: MappingResult
18
+ column_rename: dict[str, str]
19
+
20
+
21
+ def _pydantic_field_names(model: type) -> list[tuple[str, list[str], str | None, list[str]]]:
22
+ try:
23
+ from pydantic import BaseModel
24
+ except ImportError as exc:
25
+ raise ImportError("Install tejtm with pydantic extra: pip install tejtm[pydantic]") from exc
26
+ if not isinstance(model, type) or not issubclass(model, BaseModel):
27
+ raise TypeError("model must be a Pydantic BaseModel subclass")
28
+
29
+ out: list[tuple[str, list[str], str | None, list[str]]] = []
30
+ for name, field_info in model.model_fields.items():
31
+ aliases: list[str] = []
32
+ extra = field_info.json_schema_extra
33
+ if isinstance(extra, dict):
34
+ raw = extra.get("aliases") or extra.get("tejtm_aliases")
35
+ if isinstance(raw, str):
36
+ aliases.append(raw)
37
+ elif isinstance(raw, (list, tuple)):
38
+ aliases.extend(str(a) for a in raw)
39
+ title = field_info.title or name
40
+ expected = None
41
+ exemplars: list[str] = []
42
+ if isinstance(extra, dict):
43
+ if extra.get("semantic_type"):
44
+ expected = str(extra["semantic_type"])
45
+ raw_ex = extra.get("tejtm_exemplars") or extra.get("exemplar_samples")
46
+ if isinstance(raw_ex, str):
47
+ exemplars.append(raw_ex)
48
+ elif isinstance(raw_ex, (list, tuple)):
49
+ exemplars.extend(str(x) for x in raw_ex)
50
+ out.append((name, [title, *aliases], expected, exemplars))
51
+ return out
52
+
53
+
54
+ def target_schema_from_model(model: type) -> TargetSchema:
55
+ fields = [
56
+ TargetField(
57
+ id=name,
58
+ labels=(labels[0],) if labels else (),
59
+ aliases=tuple(labels[1:]),
60
+ expected_semantic_type=expected,
61
+ exemplar_samples=tuple(exemplars),
62
+ )
63
+ for name, labels, expected, exemplars in _pydantic_field_names(model)
64
+ ]
65
+ return TargetSchema(fields=fields)
66
+
67
+
68
+ def propose_and_apply(
69
+ model: type[T],
70
+ rows: Sequence[Mapping[str, Any]],
71
+ *,
72
+ config: MappingConfig | None = None,
73
+ engine: MappingEngine | None = None,
74
+ ) -> ImportResult:
75
+ if not rows:
76
+ return ImportResult(valid_rows=[], errors=[], mapping=MappingResult((), (), ()), column_rename={})
77
+
78
+ columns = list(rows[0].keys())
79
+ samples_by_col: dict[str, list[str]] = {c: [] for c in columns}
80
+ for row in rows[:50]:
81
+ for c in columns:
82
+ val = row.get(c)
83
+ if val is not None and str(val).strip():
84
+ samples_by_col[c].append(str(val))
85
+
86
+ source = SourceProfile(
87
+ columns=[SourceColumn(name=c, samples=tuple(samples_by_col[c][:20])) for c in columns]
88
+ )
89
+ target = target_schema_from_model(model)
90
+ eng = engine or MappingEngine()
91
+ mapping = eng.propose_mappings(source, target, config=config)
92
+ rename = {m.source_column: m.target_field for m in mapping.mappings}
93
+
94
+ valid: list[Any] = []
95
+ errors: list[dict[str, Any]] = []
96
+ field_types = {f.id: f.expected_semantic_type or "freetext" for f in target.fields if f.expected_semantic_type}
97
+
98
+ for idx, row in enumerate(rows):
99
+ renamed = {rename.get(k, k): v for k, v in row.items()}
100
+ coerced = coerce_record(renamed, field_types)
101
+ try:
102
+ valid.append(model.model_validate(coerced))
103
+ except Exception as exc:
104
+ errors.append({"row": idx + 1, "message": str(exc)})
105
+
106
+ return ImportResult(valid_rows=valid, errors=errors, mapping=mapping, column_rename=rename)
@@ -0,0 +1,22 @@
1
+ from tejtm.scorers.exact import ExactNameScorer
2
+ from tejtm.scorers.fuzzy import FuzzyNameScorer
3
+ from tejtm.scorers.header_embedding import HeaderEmbeddingScorer
4
+ from tejtm.scorers.semantic_type import SemanticTypeScorer
5
+ from tejtm.scorers.value_intent import ValueIntentScorer
6
+
7
+ DEFAULT_SCORERS = (
8
+ ExactNameScorer(),
9
+ FuzzyNameScorer(),
10
+ SemanticTypeScorer(),
11
+ HeaderEmbeddingScorer(),
12
+ ValueIntentScorer(),
13
+ )
14
+
15
+ __all__ = [
16
+ "DEFAULT_SCORERS",
17
+ "ExactNameScorer",
18
+ "FuzzyNameScorer",
19
+ "HeaderEmbeddingScorer",
20
+ "SemanticTypeScorer",
21
+ "ValueIntentScorer",
22
+ ]
tejtm/scorers/base.py ADDED
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Protocol
5
+
6
+ from tejtm.models import MappingConfig, ScorerResult, SourceColumn, TargetField, TypeGuess
7
+
8
+
9
+ @dataclass
10
+ class ScoringContext:
11
+ config: MappingConfig
12
+ column_types: dict[str, TypeGuess] = field(default_factory=dict)
13
+ field_embeddings: dict[str, tuple[float, ...]] | None = None
14
+
15
+
16
+ class Scorer(Protocol):
17
+ name: str
18
+
19
+ def score(
20
+ self,
21
+ column: SourceColumn,
22
+ field: TargetField,
23
+ context: ScoringContext,
24
+ ) -> ScorerResult | None: ...
25
+
26
+
27
+ def aggregate_scores(parts: list[ScorerResult]) -> float:
28
+ if not parts:
29
+ return 0.0
30
+ num = sum(p.score * p.weight for p in parts)
31
+ den = sum(p.weight for p in parts)
32
+ if den <= 0:
33
+ return 0.0
34
+ return max(0.0, min(1.0, num / den))
tejtm/scorers/exact.py ADDED
@@ -0,0 +1,19 @@
1
+ from __future__ import annotations
2
+
3
+ from tejtm.models import DEFAULT_WEIGHTS, ScorerResult, SourceColumn, TargetField
4
+ from tejtm.normalize import candidate_names, normalize_identifier
5
+ from tejtm.scorers.base import ScoringContext
6
+
7
+
8
+ class ExactNameScorer:
9
+ name = "exact_name"
10
+
11
+ def score(self, column: SourceColumn, field: TargetField, context: ScoringContext) -> ScorerResult | None:
12
+ col = normalize_identifier(column.name)
13
+ if not col:
14
+ return None
15
+ names = candidate_names(field.id, field.labels, field.aliases)
16
+ if col in names:
17
+ w = context.config.weight(self.name, DEFAULT_WEIGHTS[self.name])
18
+ return ScorerResult(scorer=self.name, score=1.0, weight=w, reason="exact normalized match")
19
+ return None
tejtm/scorers/fuzzy.py ADDED
@@ -0,0 +1,28 @@
1
+ from __future__ import annotations
2
+
3
+ from rapidfuzz import fuzz
4
+
5
+ from tejtm.models import DEFAULT_WEIGHTS, ScorerResult, SourceColumn, TargetField
6
+ from tejtm.normalize import candidate_names, normalize_identifier, tokenize_identifier
7
+ from tejtm.scorers.base import ScoringContext
8
+
9
+
10
+ class FuzzyNameScorer:
11
+ name = "fuzzy_name"
12
+
13
+ def score(self, column: SourceColumn, field: TargetField, context: ScoringContext) -> ScorerResult | None:
14
+ col_norm = normalize_identifier(column.name)
15
+ col_tokens = " ".join(tokenize_identifier(column.name))
16
+ if not col_norm:
17
+ return None
18
+ best = 0.0
19
+ for cand in candidate_names(field.id, field.labels, field.aliases):
20
+ scores = [
21
+ fuzz.ratio(col_norm, cand) / 100.0,
22
+ fuzz.WRatio(col_norm, cand) / 100.0,
23
+ fuzz.token_sort_ratio(col_tokens or col_norm, cand) / 100.0,
24
+ fuzz.token_set_ratio(col_tokens or col_norm, cand) / 100.0,
25
+ ]
26
+ best = max(best, max(scores))
27
+ w = context.config.weight(self.name, DEFAULT_WEIGHTS[self.name])
28
+ return ScorerResult(scorer=self.name, score=best, weight=w, reason=f"fuzzy best={best:.2f}")
@@ -0,0 +1,69 @@
1
+ from __future__ import annotations
2
+
3
+ import math
4
+
5
+ from tejtm.models import DEFAULT_WEIGHTS, ScorerResult, SourceColumn, TargetField
6
+ from tejtm.normalize import candidate_names
7
+ from tejtm.scorers.base import ScoringContext
8
+
9
+ _embedder = None
10
+
11
+
12
+ def _get_embedder():
13
+ global _embedder
14
+ if _embedder is False:
15
+ return None
16
+ if _embedder is not None and _embedder is not False:
17
+ return _embedder
18
+ try:
19
+ from fastembed import TextEmbedding
20
+
21
+ _embedder = TextEmbedding(
22
+ model_name="sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
23
+ )
24
+ except ImportError:
25
+ _embedder = False
26
+ return _embedder if _embedder is not False else None
27
+
28
+
29
+ def _cosine(a: tuple[float, ...], b: tuple[float, ...]) -> float:
30
+ dot = sum(x * y for x, y in zip(a, b, strict=True))
31
+ na = math.sqrt(sum(x * x for x in a))
32
+ nb = math.sqrt(sum(y * y for y in b))
33
+ if na == 0 or nb == 0:
34
+ return 0.0
35
+ return max(0.0, min(1.0, dot / (na * nb)))
36
+
37
+
38
+ class HeaderEmbeddingScorer:
39
+ name = "header_embedding"
40
+
41
+ def score(self, column: SourceColumn, field: TargetField, context: ScoringContext) -> ScorerResult | None:
42
+ model = _get_embedder()
43
+ if model is None:
44
+ return None
45
+ if context.field_embeddings is None:
46
+ return None
47
+ col_vec = _embed_text(model, column.name)
48
+ if col_vec is None:
49
+ return None
50
+ best = 0.0
51
+ for cand in candidate_names(field.id, field.labels, field.aliases):
52
+ fvec = context.field_embeddings.get(cand)
53
+ if fvec is None:
54
+ fvec = _embed_text(model, cand)
55
+ if fvec:
56
+ context.field_embeddings[cand] = fvec
57
+ if fvec:
58
+ best = max(best, _cosine(col_vec, fvec))
59
+ w = context.config.weight(self.name, DEFAULT_WEIGHTS[self.name])
60
+ return ScorerResult(scorer=self.name, score=best, weight=w, reason=f"embedding cosine={best:.2f}")
61
+
62
+
63
+ def _embed_text(model, text: str) -> tuple[float, ...] | None:
64
+ if not text.strip():
65
+ return None
66
+ vectors = list(model.embed([text]))
67
+ if not vectors:
68
+ return None
69
+ return tuple(float(x) for x in vectors[0])
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+
3
+ from tejtm.models import DEFAULT_WEIGHTS, ScorerResult, SourceColumn, TargetField
4
+ from tejtm.scorers.base import ScoringContext
5
+ from tejtm.typing.detectors import type_compatibility
6
+
7
+
8
+ class SemanticTypeScorer:
9
+ name = "semantic_type"
10
+
11
+ def score(self, column: SourceColumn, field: TargetField, context: ScoringContext) -> ScorerResult | None:
12
+ if field.expected_semantic_type is None:
13
+ return None
14
+ guess = context.column_types.get(column.name)
15
+ if guess is None:
16
+ return None
17
+ score = type_compatibility(guess, field.expected_semantic_type)
18
+ if score <= 0:
19
+ return ScorerResult(
20
+ scorer=self.name,
21
+ score=0.0,
22
+ weight=context.config.weight(self.name, DEFAULT_WEIGHTS[self.name]),
23
+ reason=f"source={guess.label} expected={field.expected_semantic_type}",
24
+ )
25
+ w = context.config.weight(self.name, DEFAULT_WEIGHTS[self.name])
26
+ return ScorerResult(
27
+ scorer=self.name,
28
+ score=score,
29
+ weight=w,
30
+ reason=f"source={guess.label} expected={field.expected_semantic_type}",
31
+ )
@@ -0,0 +1,70 @@
1
+ from __future__ import annotations
2
+
3
+ from tejtm.models import DEFAULT_WEIGHTS, ScorerResult, SourceColumn, TargetField
4
+ from tejtm.scorers.base import ScoringContext
5
+ from tejtm.value_profile import expectation_from_type, signature_similarity, value_signature
6
+
7
+
8
+ class ValueIntentScorer:
9
+ name = "value_intent"
10
+
11
+ def score(self, column: SourceColumn, field: TargetField, context: ScoringContext) -> ScorerResult | None:
12
+ column_sig = value_signature(column.samples)
13
+ if column_sig is None:
14
+ return None
15
+
16
+ scores: list[tuple[float, str]] = []
17
+
18
+ if field.expected_semantic_type:
19
+ expected_sig = expectation_from_type(field.expected_semantic_type)
20
+ sim = signature_similarity(column_sig, expected_sig)
21
+ scores.append((sim, f"type_profile:{field.expected_semantic_type}:{sim:.2f}"))
22
+
23
+ if field.exemplar_samples:
24
+ exemplar_sig = value_signature(field.exemplar_samples)
25
+ if exemplar_sig is not None:
26
+ sim = signature_similarity(column_sig, exemplar_sig)
27
+ scores.append((sim, f"exemplar:{sim:.2f}"))
28
+
29
+ embed_score = _description_value_embedding_score(column, field, context)
30
+ if embed_score is not None:
31
+ scores.append((embed_score, f"desc_embed:{embed_score:.2f}"))
32
+
33
+ if not scores:
34
+ return None
35
+
36
+ best_score, reason = max(scores, key=lambda t: t[0])
37
+ if best_score <= 0:
38
+ return None
39
+ w = context.config.weight(self.name, DEFAULT_WEIGHTS[self.name])
40
+ return ScorerResult(scorer=self.name, score=best_score, weight=w, reason=reason)
41
+
42
+
43
+ def _description_value_embedding_score(
44
+ column: SourceColumn,
45
+ field: TargetField,
46
+ context: ScoringContext,
47
+ ) -> float | None:
48
+ if not field.description:
49
+ return None
50
+ if context.field_embeddings is None:
51
+ return None
52
+ from tejtm.scorers.header_embedding import _cosine, _embed_text, _get_embedder
53
+
54
+ model = _get_embedder()
55
+ if model is None:
56
+ return None
57
+ samples = [s for s in column.samples if s and str(s).strip()][:5]
58
+ if not samples:
59
+ return None
60
+ col_text = " | ".join(samples)
61
+ field_text = f"{field.id} {field.description}"
62
+ col_vec = _embed_text(model, col_text)
63
+ field_vec = context.field_embeddings.get(f"desc:{field.id}")
64
+ if field_vec is None:
65
+ field_vec = _embed_text(model, field_text)
66
+ if field_vec:
67
+ context.field_embeddings[f"desc:{field.id}"] = field_vec
68
+ if col_vec is None or field_vec is None:
69
+ return None
70
+ return _cosine(col_vec, field_vec)
tejtm/serialization.py ADDED
@@ -0,0 +1,22 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from tejtm.models import MappingResult
6
+
7
+
8
+ def mapping_to_yaml(result: MappingResult) -> str:
9
+ try:
10
+ import yaml
11
+ except ImportError as exc:
12
+ raise ImportError("Install tejtm with yaml extra: pip install tejtm[yaml]") from exc
13
+ return yaml.safe_dump(result.to_dict(), sort_keys=False)
14
+
15
+
16
+ def mapping_from_yaml(text: str) -> MappingResult:
17
+ try:
18
+ import yaml
19
+ except ImportError as exc:
20
+ raise ImportError("Install tejtm with yaml extra: pip install tejtm[yaml]") from exc
21
+ data: Any = yaml.safe_load(text)
22
+ return MappingResult.from_dict(data or {})
@@ -0,0 +1,3 @@
1
+ from tejtm.typing.detectors import detect_semantic_type, type_compatibility
2
+
3
+ __all__ = ["detect_semantic_type", "type_compatibility"]
@@ -0,0 +1,113 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from datetime import datetime
5
+ from typing import Callable, Sequence
6
+
7
+ from tejtm.models import TypeGuess
8
+
9
+ _EMAIL = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$", re.IGNORECASE)
10
+ _URL = re.compile(r"^https?://\S+$", re.IGNORECASE)
11
+ _UUID = re.compile(
12
+ r"^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$",
13
+ re.IGNORECASE,
14
+ )
15
+ _PHONE = re.compile(r"^\+?[\d\s().-]{7,}$")
16
+ _DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
17
+ _DATETIME = re.compile(r"^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}")
18
+ _TIME = re.compile(r"^\d{1,2}:\d{2}(:\d{2})?$")
19
+ _CURRENCY = re.compile(
20
+ r"^[\$€£]\s*-?\d{1,3}(?:,\d{3})*(?:\.\d+)?$|"
21
+ r"^-?\d{1,3}(?:,\d{3})+(?:\.\d+)?$"
22
+ )
23
+ _BOOL = {"true", "false", "yes", "no", "0", "1", "y", "n"}
24
+
25
+
26
+ def _match_rate(samples: Sequence[str], predicate: Callable[[str], bool]) -> tuple[float, str]:
27
+ values = [s.strip() for s in samples if s and str(s).strip()]
28
+ if not values:
29
+ return 0.0, "no samples"
30
+ hits = sum(1 for v in values if predicate(v))
31
+ rate = hits / len(values)
32
+ return rate, f"{hits}/{len(values)} matched"
33
+
34
+
35
+ def detect_semantic_type(samples: Sequence[str]) -> TypeGuess:
36
+ checks: list[tuple[str, Callable[[str], bool]]] = [
37
+ ("email", lambda v: bool(_EMAIL.match(v))),
38
+ ("url", lambda v: bool(_URL.match(v))),
39
+ ("uuid", lambda v: bool(_UUID.match(v))),
40
+ ("phone", _is_phone),
41
+ ("datetime", lambda v: bool(_DATETIME.match(v))),
42
+ ("date", lambda v: bool(_DATE.match(v)) and _parse_date(v)),
43
+ ("time", lambda v: bool(_TIME.match(v))),
44
+ ("boolean", lambda v: v.casefold() in _BOOL),
45
+ ("currency", lambda v: bool(_CURRENCY.match(v.replace(" ", "")))),
46
+ ("integer", _is_int),
47
+ ("number", _is_number),
48
+ ]
49
+ best_label = "freetext"
50
+ best_rate = 0.0
51
+ best_evidence = ""
52
+ for label, pred in checks:
53
+ rate, evidence = _match_rate(samples, pred)
54
+ if rate > best_rate:
55
+ best_rate = rate
56
+ best_label = label
57
+ best_evidence = evidence
58
+ if best_rate < 0.5:
59
+ return TypeGuess(label="freetext", confidence=best_rate, evidence=best_evidence or "weak signal")
60
+ return TypeGuess(label=best_label, confidence=best_rate, evidence=best_evidence)
61
+
62
+
63
+ def _is_phone(value: str) -> bool:
64
+ if _DATE.match(value) or _DATETIME.match(value) or _TIME.match(value):
65
+ return False
66
+ if not _PHONE.match(value):
67
+ return False
68
+ digits = sum(c.isdigit() for c in value)
69
+ if digits < 7:
70
+ return False
71
+ return not value.isdigit()
72
+
73
+
74
+ def _is_int(value: str) -> bool:
75
+ try:
76
+ int(value.replace(",", ""))
77
+ return "." not in value
78
+ except ValueError:
79
+ return False
80
+
81
+
82
+ def _is_number(value: str) -> bool:
83
+ try:
84
+ float(value.replace(",", "").replace("$", "").replace("€", "").replace("£", ""))
85
+ return True
86
+ except ValueError:
87
+ return False
88
+
89
+
90
+ def _parse_date(value: str) -> bool:
91
+ try:
92
+ datetime.strptime(value, "%Y-%m-%d")
93
+ return True
94
+ except ValueError:
95
+ return False
96
+
97
+
98
+ def type_compatibility(source: TypeGuess, expected: str | None) -> float:
99
+ if expected is None:
100
+ return 0.0
101
+ if source.label == expected:
102
+ return min(1.0, 0.5 + 0.5 * source.confidence)
103
+ compatible = {
104
+ ("integer", "number"),
105
+ ("number", "integer"),
106
+ ("datetime", "date"),
107
+ ("date", "datetime"),
108
+ ("number", "currency"),
109
+ ("currency", "number"),
110
+ }
111
+ if (source.label, expected) in compatible:
112
+ return 0.6 * source.confidence
113
+ return 0.0
tejtm/value_profile.py ADDED
@@ -0,0 +1,108 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+ from typing import Sequence
6
+
7
+ from tejtm.models import TypeGuess
8
+ from tejtm.typing.detectors import detect_semantic_type, type_compatibility
9
+
10
+ _WS = re.compile(r"\s")
11
+
12
+
13
+ @dataclass(frozen=True, slots=True)
14
+ class ValueSignature:
15
+ semantic_type: str
16
+ type_confidence: float
17
+ avg_length: float
18
+ digit_ratio: float
19
+ space_ratio: float
20
+ distinct_ratio: float
21
+ uppercase_ratio: float
22
+ token_prefix: str = ""
23
+
24
+
25
+ def value_signature(samples: Sequence[str]) -> ValueSignature | None:
26
+ values = [str(s).strip() for s in samples if s is not None and str(s).strip()]
27
+ if not values:
28
+ return None
29
+ guess = detect_semantic_type(values)
30
+ total_chars = sum(len(v) for v in values)
31
+ digits = sum(sum(c.isdigit() for c in v) for v in values)
32
+ spaces = sum(len(_WS.findall(v)) for v in values)
33
+ distinct = len(set(values)) / len(values)
34
+ upper = sum(1 for v in values if v.isupper()) / len(values)
35
+ return ValueSignature(
36
+ semantic_type=guess.label,
37
+ type_confidence=guess.confidence,
38
+ avg_length=total_chars / len(values),
39
+ digit_ratio=digits / total_chars if total_chars else 0.0,
40
+ space_ratio=spaces / total_chars if total_chars else 0.0,
41
+ distinct_ratio=distinct,
42
+ uppercase_ratio=upper,
43
+ token_prefix=_dominant_prefix(values),
44
+ )
45
+
46
+
47
+ def signature_similarity(a: ValueSignature, b: ValueSignature) -> float:
48
+ type_score = type_compatibility(
49
+ TypeGuess(a.semantic_type, a.type_confidence),
50
+ b.semantic_type,
51
+ )
52
+ if type_score <= 0 and a.semantic_type != b.semantic_type:
53
+ type_part = 0.15 if _types_loosely_compatible(a.semantic_type, b.semantic_type) else 0.0
54
+ else:
55
+ type_part = type_score
56
+
57
+ prefix_part = 1.0 if a.token_prefix and a.token_prefix == b.token_prefix else 0.0
58
+ shape = 1.0 - min(
59
+ 1.0,
60
+ (
61
+ abs(a.digit_ratio - b.digit_ratio)
62
+ + abs(a.space_ratio - b.space_ratio)
63
+ + abs(a.distinct_ratio - b.distinct_ratio) * 0.5
64
+ + abs(a.uppercase_ratio - b.uppercase_ratio) * 0.5
65
+ + min(1.0, abs(a.avg_length - b.avg_length) / max(a.avg_length, b.avg_length, 1.0))
66
+ )
67
+ / 3.0,
68
+ )
69
+ blended = 0.45 * type_part + 0.35 * shape + 0.20 * prefix_part
70
+ return max(0.0, min(1.0, blended))
71
+
72
+
73
+ def expectation_from_type(expected_semantic_type: str) -> ValueSignature:
74
+ profiles: dict[str, ValueSignature] = {
75
+ "email": ValueSignature("email", 1.0, 22.0, 0.0, 0.0, 0.9, 0.0),
76
+ "phone": ValueSignature("phone", 1.0, 12.0, 0.5, 0.05, 0.9, 0.0),
77
+ "uuid": ValueSignature("uuid", 1.0, 36.0, 0.0, 0.0, 1.0, 0.0),
78
+ "url": ValueSignature("url", 1.0, 30.0, 0.05, 0.0, 0.9, 0.0),
79
+ "date": ValueSignature("date", 1.0, 10.0, 0.3, 0.0, 0.8, 0.0),
80
+ "datetime": ValueSignature("datetime", 1.0, 19.0, 0.35, 0.05, 0.8, 0.0),
81
+ "integer": ValueSignature("integer", 1.0, 4.0, 0.9, 0.0, 0.7, 0.0),
82
+ "number": ValueSignature("number", 1.0, 6.0, 0.4, 0.0, 0.7, 0.0),
83
+ "currency": ValueSignature("currency", 1.0, 8.0, 0.5, 0.0, 0.6, 0.0),
84
+ "boolean": ValueSignature("boolean", 1.0, 4.0, 0.0, 0.0, 0.3, 0.0),
85
+ "freetext": ValueSignature("freetext", 0.5, 16.0, 0.1, 0.12, 0.85, 0.1),
86
+ }
87
+ return profiles.get(expected_semantic_type, profiles["freetext"])
88
+
89
+
90
+ def _dominant_prefix(values: Sequence[str]) -> str:
91
+ buckets: dict[str, int] = {}
92
+ for v in values:
93
+ token = v.strip()
94
+ if "-" in token:
95
+ key = token.split("-", 1)[0].casefold()
96
+ elif len(token) >= 3:
97
+ key = token[:3].casefold()
98
+ else:
99
+ key = token.casefold()
100
+ buckets[key] = buckets.get(key, 0) + 1
101
+ if not buckets:
102
+ return ""
103
+ return max(buckets, key=buckets.get)
104
+
105
+
106
+ def _types_loosely_compatible(a: str, b: str) -> bool:
107
+ pairs = {("integer", "number"), ("date", "datetime"), ("freetext", "uuid")}
108
+ return (a, b) in pairs or (b, a) in pairs
@@ -0,0 +1,113 @@
1
+ Metadata-Version: 2.5
2
+ Name: tejtm
3
+ Version: 0.1.0
4
+ Summary: Schema mapping with composite confidence for tabular imports
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: rapidfuzz>=3.0
7
+ Provides-Extra: dev
8
+ Requires-Dist: email-validator>=2.0; extra == 'dev'
9
+ Requires-Dist: openpyxl>=3.1; extra == 'dev'
10
+ Requires-Dist: pydantic>=2.0; extra == 'dev'
11
+ Requires-Dist: pytest>=8.0; extra == 'dev'
12
+ Provides-Extra: phone
13
+ Requires-Dist: phonenumbers>=8.13; extra == 'phone'
14
+ Provides-Extra: pydantic
15
+ Requires-Dist: email-validator>=2.0; extra == 'pydantic'
16
+ Requires-Dist: pydantic>=2.0; extra == 'pydantic'
17
+ Provides-Extra: semantic
18
+ Requires-Dist: fastembed>=0.4; extra == 'semantic'
19
+ Provides-Extra: yaml
20
+ Requires-Dist: pyyaml>=6; extra == 'yaml'
21
+ Description-Content-Type: text/markdown
22
+
23
+ # tejtm
24
+
25
+ tejtm maps messy import columns to a target schema you define. It returns one-to-one pairs with a composite confidence and a per-scorer breakdown. It does not read files, join records, or validate rows.
26
+
27
+ ## Install
28
+
29
+ ```bash
30
+ pip install -e ".[dev]"
31
+ ```
32
+
33
+ Optional extras:
34
+
35
+ ```bash
36
+ pip install -e ".[semantic,pydantic,yaml]"
37
+ ```
38
+
39
+ `semantic` adds FastEmbed header similarity. `pydantic` builds a target schema from a model. `yaml` saves mapping results.
40
+
41
+ ## Map columns to a target schema
42
+
43
+ The library takes column names plus sample values. You keep file I/O in your app.
44
+
45
+ ```python
46
+ from tejtm import MappingEngine, MappingConfig, SourceColumn, SourceProfile, TargetField, TargetSchema
47
+
48
+ source = SourceProfile(columns=[
49
+ SourceColumn("phone", samples=("+1 555-0100",)),
50
+ SourceColumn("contact", samples=("user@example.com",)),
51
+ SourceColumn("Cust Name", samples=("Ada Lovelace",)),
52
+ ])
53
+ target = TargetSchema(fields=[
54
+ TargetField("phone_number", aliases=("phone",), expected_semantic_type="phone"),
55
+ TargetField("email", expected_semantic_type="email"),
56
+ TargetField("full_name", aliases=("name", "customer name")),
57
+ ])
58
+ result = MappingEngine().propose_mappings(
59
+ source,
60
+ target,
61
+ config=MappingConfig(min_confidence=0.5),
62
+ )
63
+ for mapping in result.mappings:
64
+ print(mapping.source_column, "->", mapping.target_field, mapping.confidence)
65
+ print("unmapped", result.unmapped_columns)
66
+ print("unmatched", result.unmatched_fields)
67
+ ```
68
+
69
+ You should see `phone -> phone_number`, `contact -> email`, and `Cust Name -> full_name`. Weak pairs stay out of `mappings` and land in `unmapped_columns` or `unmatched_fields`.
70
+
71
+ Default `min_confidence` is `0.55`. Lower it for messy vendor headers, then auto-apply only the pairs above a threshold you pick.
72
+
73
+ ## Apply the mapping
74
+
75
+ tejtm does not rewrite rows. Use `source_column` and `target_field` to rename keys in your own loop. For cell casting after rename, call `coerce_value` or `coerce_record`. For a Pydantic model, see [How to customize mapping](docs/customization.md).
76
+
77
+ ## Fixture results
78
+
79
+ The six directories under `tests/fixtures/scenarios/` are the only sector samples in this repo. They are small synthetic tables, not a customer corpus. Treat a fixture as passing when every `expect_mappings` pair lands in `result.mappings` at that fixture's `min_confidence`.
80
+
81
+ Measured 29 Oct 2026 in this checkout, FastEmbed absent, two identical runs. `header_embedding` abstained. Scores come from `exact_name`, `fuzzy_name`, `semantic_type`, and `value_intent` only. The limiter is fixture size (2 or 3 columns) and those lowered thresholds, not production files.
82
+
83
+ | Fixture | Format | min_confidence | Mapped / expected | Lowest mapped pair |
84
+ | --- | --- | --- | --- | --- |
85
+ | `crm_contacts` | csv | 0.45 | 3 / 3 | `contact` -> `email` (0.549) |
86
+ | `edge_duplicate_generic_headers` | csv | 0.35 | 2 / 2 | `ref_a` -> `order_reference` (0.601) |
87
+ | `finance_ledger` | xlsx | 0.4 | 3 / 3 | `GL Code` -> `ledger_account` (0.752) |
88
+ | `healthcare_appointments` | csv | 0.4 | 3 / 3 | `Clinic` -> `facility_name` (0.480) |
89
+ | `hr_payroll` | csv | 0.4 | 3 / 3 | `Gross Pay` -> `gross_amount` (0.569) |
90
+ | `warehouse_inventory` | csv | 0.4 | 3 / 3 | `Bin Loc` -> `storage_location` (0.807) |
91
+
92
+ All 17 expected pairs mapped at those fixture thresholds. At the library default of `0.55`, 15 of 17 still map. The two that drop are `contact` -> `email` in `crm_contacts` (0.549) and `Clinic` -> `facility_name` in `healthcare_appointments` (0.480). See [About mapping limits](docs/limitations.md) for why demos sit below the default.
93
+
94
+ `.venv/bin/python -m pytest -q` reported 52 passed in this checkout. That count is the test suite, not a mapping-accuracy figure.
95
+
96
+ Regenerate the table:
97
+
98
+ ```bash
99
+ .venv/bin/python tests/support/fixture_stats.py
100
+ ```
101
+
102
+ ## What to read next
103
+
104
+ - [How to customize mapping](docs/customization.md) covers weights, overrides, custom scorers, exemplars, Pydantic, and YAML.
105
+ - [About mapping limits](docs/limitations.md) covers joins, duplicate headers, optional extras, and unused config.
106
+ - [Product spec](docs/specs/schema-mapping-spec.md) is the v1 contract.
107
+ - [Glossary](docs/GLOSSARY.md) defines source column, target field, and value intent.
108
+
109
+ Run tests with the project virtualenv:
110
+
111
+ ```bash
112
+ .venv/bin/python -m pytest -q
113
+ ```
@@ -0,0 +1,21 @@
1
+ tejtm/__init__.py,sha256=VAnT4aWGRuKJd0zL3Ghx3rCoSsaFDCuCafcn4r0RLTM,584
2
+ tejtm/assignment.py,sha256=kKLhc_R30G6XbUJ9BpHUzT0s9MUP083vb595-KUz-JU,2063
3
+ tejtm/coercion.py,sha256=1w2UMNMWizkxD3aFyIgni-CGuHVY60ZGkaJcL_J3B18,1632
4
+ tejtm/engine.py,sha256=ARrqP056Fp11eFPP2e9SF4dPPO_rVCUZ9fy0vh0E_IM,4321
5
+ tejtm/models.py,sha256=rIehZReUv6qIlzVFUOFJ-lvtAmJrjrWr01jgeKhfjTc,3892
6
+ tejtm/normalize.py,sha256=ze54_b9_2NYixbVTvDZVckHupooMUonPWojMsaVoGmo,1315
7
+ tejtm/pydantic_adapter.py,sha256=jYb0itc51lsPjkYYmx80HbJjG1bT6faMG0xqYuzxLLs,3982
8
+ tejtm/serialization.py,sha256=PVZ_BgA0qOiCESbBa9-yccZYfEiIr7Ok97UctcX458Y,656
9
+ tejtm/value_profile.py,sha256=2tr8lTPtLW-lyknDtHuL7rE7M9TofCT8GjP6yVoWjM4,4053
10
+ tejtm/scorers/__init__.py,sha256=3cnOd2QnmqJtoa0JMLNOhWnx891TkXli4EP-Y_ARx1I,590
11
+ tejtm/scorers/base.py,sha256=R93JjO45TjGLtMqdG9-gVBrjPBNHEx_xiskDeYkUJ1o,849
12
+ tejtm/scorers/exact.py,sha256=-NesDzCycBX5gYbbWJYqJTv7fmbR0SbuV8NppIon9Mc,780
13
+ tejtm/scorers/fuzzy.py,sha256=It7H_ME19WKDd_lX5JY0MRvy-PSiWaFff_n58a4Ztao,1212
14
+ tejtm/scorers/header_embedding.py,sha256=FuhSZUMrOo20q7yIrB1_oLV-1jk4tYe7b13adZcvkaU,2234
15
+ tejtm/scorers/semantic_type.py,sha256=A_o6lw6WoMhuuIksRXx5Lln9mO5IA851w2vRpp6JkJ4,1211
16
+ tejtm/scorers/value_intent.py,sha256=wld6P2n2KSpVs5aoFDxEZ7RKgmK5AfK202LiT3kwSuc,2623
17
+ tejtm/typing/__init__.py,sha256=lp8D-9bz25HKpp3i13dMdBFr-CB11L8Bzr81C7vd4Qk,134
18
+ tejtm/typing/detectors.py,sha256=mgBj6HHHMC_iijnivtZH0YIgJbIT1M3jrGI-qJ1gIlM,3619
19
+ tejtm-0.1.0.dist-info/METADATA,sha256=xVoAyLiNfZz2IVhEiV3k9332tLEShEyd0LPCiEMX_Eo,5067
20
+ tejtm-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
21
+ tejtm-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any