CoolFace
Apppublic

CMacD/AIC_PHASE1_POC

sourceHugging Faceupdated 4mo agoView on Hugging Face
0likes
mapping_lookup.py616 linesDownload Raw Back to ml_package
1#!/usr/bin/env python2# coding: utf-83"""4MappingLookup — fuzzy historical match engine  (Phase 1, Step 1).5 6This is the first stage of the pipeline and the most straightforward one.7For every new product in the flat file it asks: have we seen something like8this before in the historical FINAL data?  If yes, carry the historical label9across.  If no, find the closest thing and flag it for analyst review.10 11What it does12------------13  1. Builds a single lookup key per product by concatenating the relevant14     attribute columns — for example, RAW_BRAND + RAW_SUB_BRAND becomes one15     string that represents the product's identity for that attribute.16 17  2. Does a direct join against the historical data first.  Anything that18     matches exactly gets score=100 and is done.19 20  3. For products that do not match exactly, runs vectorised rapidfuzz21     token_set_ratio scoring against all known historical keys.  The minimum22     of token_set_ratio and token_sort_ratio is used as the final score so23     that partial string matches like "KRAFT" vs "KRAFT HEINZ" are not24     artificially inflated.  Suggestions below 50 are dropped as noise.25 26  4. If a product still has no suggestion after fuzzy scoring, and the27     attribute has a recognised catch-all label in its vocabulary (e.g.28     "ALL OTHER BRANDS", "AO COFFEE"), that product is assigned the fallback29     label with score=0.  Score=0 guarantees HIGH QC Priority in Ensemble so30     an analyst always sees it.31 32  5. Returns one ranked lookup table per attribute plus the flat-file output33     and a flag table that tells Ensemble whether a key maps to one historical34     label or several.35 36Abbreviation expansion37----------------------38A small set of safe product-name abbreviations are expanded before matching39(CHOC → CHOCOLATE, ORG → ORGANIC etc).  Only unambiguous ones are included40here.  Category-specific synonyms should go in a synonyms.json at project41level, not in this file.42 43Code style44----------45Functions are written to be read straight through.  Steps are broken into named46variables rather than chained.  If something is not immediately obvious from the47code it has a comment.  Keep it that way.48"""49 50import re51import sqlite352import traceback53 54import numpy as np55import pandas as pd56from rapidfuzz import process as rfprocess, fuzz as rffuzz57 58 59# ── Key normalisation ─────────────────────────────────────────────────────────60# Joining attribute tokens with a space (not '') lets token_set_ratio correctly61# tokenise "DARK CHOCOLATE" rather than matching against "DARKCHOCOLATE".62_ABBREV = {63    'CHOC': 'CHOCOLATE',64    'NAT':  'NATURAL',65    'ORG':  'ORGANIC',66    'WHL':  'WHOLE',67    'LF':   'LOW FAT',68    'FF':   'FAT FREE',69    'RF':   'REDUCED FAT',70}71_ABBREV_RE = {re.compile(r'\b' + k + r'\b'): v for k, v in _ABBREV.items()}72 73# ── "All Other" bucket detection ──────────────────────────────────────────────74# Matches standard catch-all label conventions used across Circana categories:75#   "ALL OTHER"          "ALL OTHERS"          "ALL OTHER BRANDS"76#   "AO BRANDS"          "AO COFFEE"           etc.77# Deliberately excludes:78#   "AO"  alone  — too ambiguous; could be a real brand abbreviation.79#   "OTHER" alone — too generic; matches many non-catch-all labels.80# If no label in the attribute's value space matches, the fallback is skipped81# entirely and the analyst handles the gap — no invented values are written.82_AO_RE = re.compile(r'^(ALL\s+OTHERS?(?:\s|$)|AO\s+\S)', re.IGNORECASE)83 84 85def _normalise_key(text: str) -> str:86    """Uppercase, collapse whitespace, expand safe product-name abbreviations."""87    text = str(text).upper().strip()88    text = re.sub(r'\s+', ' ', text)89    for pat, rep in _ABBREV_RE.items():90        text = pat.sub(rep, text)91    return text92 93 94def _build_key(row) -> str:95    """Concatenate all values in a DataFrame row into a normalised lookup key."""96    return _normalise_key(' '.join(str(v) for v in row.values))97 98 99# ── Internal helpers ──────────────────────────────────────────────────────────100 101def _score_unmapped_keys(history_df: pd.DataFrame, attr_key_cols: list,102                          mdm_col: str) -> pd.DataFrame:103    """104    Score every unmapped product key against every mapped historical key using105    rapidfuzz token_set_ratio (vectorised).106 107    Parameters108    ----------109    history_df : DataFrame110        Aggregated historical rows with a 'combinedkey' column and the target111        MDM column.  Rows where the MDM value equals 'missing' are unmapped.112    attr_key_cols : list of str113        Raw key columns for this attribute (carried through to the output).114    mdm_col : str115        Name of the MDM column being matched.116 117    Returns118    -------119    DataFrame with columns: unmapped, mapped, {mdm_col}, score, attr_key_cols.120    Pre-mapped rows (score=100) are included alongside the scored rows.121    """122    history_df.fillna('missing', inplace=True)123 124    unmapped  = history_df[history_df[mdm_col] == 'missing'][['combinedkey'] + attr_key_cols].drop_duplicates()125    mapped    = history_df[history_df[mdm_col] != 'missing'][['combinedkey', mdm_col]].drop_duplicates()126    pre_mapped = (127        history_df[history_df[mdm_col] != 'missing'][['combinedkey', mdm_col] + attr_key_cols]128        .drop_duplicates()129        .rename(columns={'combinedkey': 'mapped'})130        .assign(score=100)131    )132 133    if unmapped.empty:134        mapped['score'] = 100135        mapped['mapped'] = mapped['combinedkey']136        return mapped137 138    # Vectorised pairwise scoring — replaces cross-join + row-by-row apply.139    unmapped_reset = unmapped.reset_index(drop=True)140    mapped_reset   = mapped.reset_index(drop=True)141    scores_mat = rfprocess.cdist(142        unmapped_reset['combinedkey'].tolist(),143        mapped_reset['combinedkey'].tolist(),144        scorer=rffuzz.token_set_ratio,145        workers=-1,146    )147    unmapped_grid, mapped_grid = np.meshgrid(148        np.arange(len(unmapped_reset)),149        np.arange(len(mapped_reset)),150        indexing='ij',151    )152    unmapped_indices = unmapped_grid.ravel()153    mapped_indices   = mapped_grid.ravel()154    scored = pd.DataFrame({155        'unmapped': unmapped_reset['combinedkey'].values[unmapped_indices],156        'mapped':   mapped_reset['combinedkey'].values[mapped_indices],157        mdm_col:    mapped_reset[mdm_col].values[mapped_indices],158        'score':    scores_mat.ravel(),159    })160    for col in attr_key_cols:161        if col in unmapped_reset.columns:162            scored[col] = unmapped_reset[col].values[unmapped_indices]163 164    return pd.concat([scored, pre_mapped], ignore_index=True)165 166 167def _size_bin_lookup(history_df: pd.DataFrame, results: pd.DataFrame,168                     attr_key_cols: list, mdm_col: str):169    """170    Map raw numeric SIZE values onto labelled size bins via an in-memory SQL171    range join — the most readable and efficient approach for range lookups.172 173    Returns (results_with_size_col, aggregated_size_sales).174    """175    size_limits = history_df[[mdm_col]].drop_duplicates().dropna()176    size_limits['lower'] = np.where(177        size_limits['SIZE'].str.contains('LESS'), 0,178        size_limits['SIZE'].str.findall(r'(\d+(?:\.\d+)?)').str[0],179    ).astype('float')180    size_limits['higher'] = np.where(181        size_limits['SIZE'].str.contains('PLUS'), 9999,182        size_limits['SIZE'].str.findall(r'(\d+(?:\.\d+)?)').str[1],183    )184    size_limits['higher'] = np.where(185        size_limits['SIZE'].str.contains('LESS'),186        size_limits['SIZE'].str.findall(r'(\d+(?:\.\d+)?)').str[0],187        size_limits['higher'],188    ).astype('float')189 190    results['combinedkey']        = results['combinedkey'].astype('float')191    history_df[attr_key_cols[0]]  = history_df[attr_key_cols[0]].astype('float')192 193    conn = sqlite3.connect(':memory:')194    history_df.to_sql('history_df',  conn, index=False)195    size_limits.to_sql('size_limits', conn, index=False)196    results.to_sql('results',        conn, index=False)197 198    results = pd.read_sql_query(199        "SELECT DISTINCT A.*, B.SIZE FROM results A "200        "LEFT JOIN size_limits B ON A.combinedkey >= B.lower AND A.combinedkey < B.higher",201        conn,202    )203    agg = pd.read_sql_query(204        f"SELECT {attr_key_cols[0]}, B.SIZE, SUM(TOTAL_UNIT_SALES) AS TOTAL_UNIT_SALES "205        f"FROM history_df A LEFT JOIN size_limits B "206        f"ON {attr_key_cols[0]} BETWEEN B.lower AND B.higher "207        f"GROUP BY {attr_key_cols[0]}, B.SIZE",208        conn,209    )210    agg['combinedkey'] = agg[attr_key_cols[0]]211    return results, agg212 213 214def _build_attribute_table(flat_file_df: pd.DataFrame, meta_df: pd.DataFrame,215                            history_df: pd.DataFrame):216    """217    Build the base result table by joining flat-file products against historical218    attribute mappings for every MDM attribute group.219 220    For each attribute:221    - Exact key matches carry the historical MDM value directly (score=100).222    - Unmatched rows are collected for fuzzy scoring in a later step.223    - A 'flag' column marks whether a key combo maps to one (0) or multiple (1)224      distinct historical labels, used by Ensemble to weight confidence.225 226    Returns227    -------228    tuple of:229        results          – base result DataFrame (one row per flat-file product)230        historical_agg   – aggregated historical records per attribute231        fuzzy_matches    – scored unmatched rows per attribute232        flat_file_combos – unique key combos from the flat file per attribute233        attr_key_map     – {mdm_col: [key_col, ...]}234        flag_map         – {mdm_col: flag DataFrame}235    """236    flat_file_df = flat_file_df.copy()237    flat_file_df['index'] = flat_file_df.index238 239    # Start results with every column present in the flat file.240    # Each attribute loop reads key columns directly from results — no merging needed.241    results = flat_file_df.copy()242 243    historical_agg   = {}244    fuzzy_matches    = {}245    flat_file_combos = {}246    attr_key_map     = {}247    flag_map         = {}248 249    for mdm_col in meta_df['Attribute Group name'].unique():250        attr_key_cols = list(meta_df.loc[meta_df['Attribute Group name'] == mdm_col,251                                         'Attribute Name in MDM'])252 253        # Key columns are already present in results (flat_file_df.copy()),254        # so no merge is needed here.255 256        if mdm_col in history_df.columns or mdm_col.replace(' ', '_') in history_df.columns:257            # Build normalised lookup keys from attribute columns258            results['key']            = flat_file_df[attr_key_cols].fillna('').apply(_build_key, axis=1)259            history_df['combinedkey'] = history_df[attr_key_cols].fillna('').apply(_build_key, axis=1)260 261            # Aggregate historical records: best label per key by sales volume262            historical = history_df.loc[263                :, history_df.columns.isin(264                    attr_key_cols + ['combinedkey', mdm_col, mdm_col.replace(' ', '_'), 'TOTAL_UNIT_SALES']265                )266            ].fillna('')267            agg_cols = [c for c in historical.columns if c != 'TOTAL_UNIT_SALES']268            historical = (269                historical.groupby(agg_cols)270                .agg(TOTAL_UNIT_SALES=('TOTAL_UNIT_SALES', sum), Rec=('TOTAL_UNIT_SALES', 'count'))271                .reset_index()272                .sort_values(agg_cols + ['TOTAL_UNIT_SALES', 'Rec'])273            )274            historical = (275                historical[historical[mdm_col] != '']276                .sort_values(attr_key_cols + ['Rec'], ascending=False)277            )278            historical['rank'] = historical.groupby(attr_key_cols).cumcount(ascending=True) + 1279            historical         = historical[historical['rank'] == 1]280 281            # Unique key combos from the flat file (for coverage reporting)282            ff_combos    = flat_file_df.loc[283                :, flat_file_df.columns.isin(284                    attr_key_cols + ['UPDATE_REQUIRED', mdm_col, mdm_col.replace(' ', '_')]285                )286            ].fillna('')287            ff_agg_cols  = [c for c in ff_combos.columns if c != 'UPDATE_REQUIRED']288            ff_combos    = ff_combos.groupby(ff_agg_cols).agg(Rec=('UPDATE_REQUIRED', 'count')).reset_index()289 290            # Resolve actual column name — historical FINAL sheets sometimes use291            # underscores where META uses spaces (e.g. 'PACK TYPE' vs 'PACK_TYPE').292            # Taking the first match normalises to whatever variant exists in history.293            mdm_col = list(history_df.columns[294                history_df.columns.isin([mdm_col, mdm_col.replace(' ', '_')])295            ])[0]296            attr_key_map[mdm_col] = attr_key_cols297 298            # Direct join: flat-file keys → best historical mapping by sales299            best_mapping = (300                historical[['combinedkey', mdm_col, 'TOTAL_UNIT_SALES', 'Rec']]301                .groupby(['combinedkey', mdm_col])302                .agg(TOTAL_UNIT_SALES=('TOTAL_UNIT_SALES', sum), Rec=('Rec', sum))303                .reset_index()304                .sort_values('TOTAL_UNIT_SALES', ascending=False)305            )306            best_mapping['rank'] = (307                best_mapping.groupby(['combinedkey'])['TOTAL_UNIT_SALES']308                .rank(method='dense', ascending=False)309            )310            results = pd.merge(311                results,312                best_mapping.loc[best_mapping['rank'] == 1, ['combinedkey', mdm_col]],313                left_on='key', right_on='combinedkey', how='left', suffixes=('', '_remove'),314            )315 316            # Flag: does this key combo map to one label (0) or multiple (1)?317            flag_df          = history_df[attr_key_cols + [mdm_col]].drop_duplicates()318            flag_df['count'] = flag_df.groupby(attr_key_cols)[mdm_col].transform('count')319            flag_df['flag']  = (flag_df['count'] > 1).astype(int)320            flag_map[mdm_col] = flag_df[attr_key_cols + ['flag']].drop_duplicates()321 322            results = results[[c for c in results.columns if 'key' not in c and '_remove' not in c]]323 324        elif mdm_col == 'SIZE':325            results['combinedkey'] = flat_file_df[attr_key_cols].fillna('').apply(_build_key, axis=1)326            results, historical    = _size_bin_lookup(history_df, results, attr_key_cols, mdm_col)327            historical['UNIT_SHARE'] = historical['TOTAL_UNIT_SALES'] / historical['TOTAL_UNIT_SALES'].sum()328            ff_combos              = historical329            attr_key_map[mdm_col]  = attr_key_cols330 331        else:332            history_df['combinedkey'] = history_df[attr_key_cols]333            results[mdm_col]          = history_df[attr_key_cols]334            historical = history_df.loc[335                :, history_df.columns.isin(336                    attr_key_cols + ['combinedkey', mdm_col, mdm_col.replace(' ', '_'), 'TOTAL_UNIT_SALES']337                )338            ].fillna('')339            agg_cols   = [c for c in historical.columns if c != 'TOTAL_UNIT_SALES']340            historical = (341                historical.groupby(agg_cols)342                .agg(TOTAL_UNIT_SALES=('TOTAL_UNIT_SALES', sum), Rec=('TOTAL_UNIT_SALES', 'count'))343                .reset_index()344                .sort_values(agg_cols + ['TOTAL_UNIT_SALES', 'Rec'])345            )346            historical['rank'] = historical.groupby(agg_cols).cumcount(ascending=False) + 1347            historical         = historical[historical['rank'] == 1]348            ff_combos          = flat_file_df.loc[349                :, flat_file_df.columns.isin(350                    attr_key_cols + ['UPDATE_REQUIRED', mdm_col, mdm_col.replace(' ', '_')]351                )352            ].fillna('missing')353            ff_agg_cols        = [c for c in ff_combos.columns if c != 'UPDATE_REQUIRED']354            ff_combos          = ff_combos.groupby(ff_agg_cols).agg(Rec=('UPDATE_REQUIRED', 'count')).reset_index()355            attr_key_map[mdm_col] = attr_key_cols356 357        historical_agg[mdm_col]   = historical358        flat_file_combos[mdm_col] = ff_combos359        fuzzy_matches[mdm_col]    = _score_unmapped_keys(historical, attr_key_cols, mdm_col)360        print(f"  Lookup   {mdm_col}: {len(ff_combos)} products to map | {len(historical)} matched to history")361 362    return results.drop(columns=['index']), historical_agg, fuzzy_matches, flat_file_combos, attr_key_map, flag_map363 364 365def _select_top_matches(fuzzy_matches: dict) -> dict:366    """367    Retain only the top-ranked fuzzy match per unmapped key.368 369    For SIZE the threshold is score=100 (exact bin match required).370    For all other attributes the top-scoring candidate per key is kept371    regardless of score — low-confidence suggestions are filtered in372    _build_lookup_table (threshold ≥50).373    """374    for mdm_col, match_df in fuzzy_matches.items():375        try:376            if 'unmapped' not in match_df.columns:377                # All items matched directly — no fuzzy ranking needed.378                continue379            match_df['rank'] = match_df.groupby('unmapped')['score'].rank(380                method='dense', ascending=False381            )382            if mdm_col == 'SIZE':383                # SIZE requires an exact bin match — partial matches are meaningless384                fuzzy_matches[mdm_col] = match_df[match_df['score'] == 100]385            else:386                fuzzy_matches[mdm_col] = match_df[match_df['rank'] == 1]387        except Exception as exc:388            print(f"  WARNING: fuzzy rank failed for '{mdm_col}': {exc} — no lookup suggestions")389    return fuzzy_matches390 391 392def _build_flat_file_output(flat_file_df: pd.DataFrame, meta_df: pd.DataFrame):393    """394    Build the FLAT_FILE output sheet from the raw flat-file data.395 396    Adds IS_NEW_UPC and RAW_IS_ACTIVE sentinel columns, and appends the397    MDM attribute columns so the sheet carries the full template structure.398    Returns (output_df, column_list).399    """400    output = flat_file_df.copy()401    output['IS_NEW_UPC']    = 0402    output['RAW_IS_ACTIVE'] = 1403    flat_file_columns = list(output.columns) + list(meta_df['Attribute Name in MDM'])404    return output, flat_file_columns405 406 407def _build_lookup_table(mapped_df: pd.DataFrame, new_products_df: pd.DataFrame,408                         attr_key_cols: list, mdm_col: str):409    """410    Build the ranked lookup table for a single attribute.411 412    Combines:413    - Directly matched products (score=100 from historical join).414    - Fuzzy-matched new products (vectorised min of token_set_ratio and415      token_sort_ratio; threshold ≥50 to suppress noise).416 417    The min-of-two-scorers approach prevents subset-match inflation —418    e.g. "KRAFT" vs "KRAFT HEINZ" scores 100 on token_set but 72 on419    token_sort, so the combined score is 72, which is more accurate.420 421    Returns (matched_df, lookup_table_df).422    lookup_table_df columns: attr_key_cols + [mdm_col, score, Rank, Record].423    'missing' values are replaced with blank before returning.424    """425    new_products_df = new_products_df.copy()426    new_products_df['mapped'] = new_products_df[attr_key_cols].fillna('').apply(_build_key, axis=1)427    directly_matched = pd.merge(new_products_df, mapped_df[['mapped', mdm_col, 'score']], how='inner')428    remaining        = new_products_df[~new_products_df['mapped'].isin(directly_matched['mapped'])]429 430    if remaining.empty:431        lookup_table = directly_matched[attr_key_cols + [mdm_col]].fillna(100).drop_duplicates()432        lookup_table['score'] = 100433        lookup_table[mdm_col] = lookup_table[mdm_col].astype(str)434    else:435        remaining_r = remaining.drop(columns=mdm_col, errors='ignore').reset_index(drop=True)436        mapped_lkp  = mapped_df[['mapped', mdm_col]].drop_duplicates('mapped').reset_index(drop=True)437 438        u_keys = remaining_r['mapped'].tolist()439        m_keys = mapped_lkp['mapped'].tolist()440 441        mat_set    = rfprocess.cdist(u_keys, m_keys, scorer=rffuzz.token_set_ratio,  workers=-1)442        mat_sort   = rfprocess.cdist(u_keys, m_keys, scorer=rffuzz.token_sort_ratio, workers=-1)443        scores_mat = np.minimum(mat_set, mat_sort)444 445        # Suppress numeric↔non-numeric cross-pairings (e.g. "16OZ" vs "CHEDDAR").446        # Broadcasting [N,1] != [1,M] produces an [N,M] boolean mask — True where447        # the unmapped key has digits and the mapped key does not (or vice versa).448        u_has_digits = np.array([bool(re.search(r'\d+', str(k))) for k in u_keys])449        m_has_digits = np.array([bool(re.search(r'\d+', str(k))) for k in m_keys])450        numeric_type_mismatch = u_has_digits[:, None] != m_has_digits[None, :]451        scores_mat[numeric_type_mismatch] = 0452 453        # Exact string match always → 100 (overrides scorer floating-point rounding).454        # Same [N,M] broadcasting: True wherever the unmapped key equals the mapped key.455        u_arr, m_arr = np.array(u_keys), np.array(m_keys)456        exact_match_mask = u_arr[:, None] == m_arr[None, :]457        scores_mat[exact_match_mask] = 100458 459        best_idx    = np.argmax(scores_mat, axis=1)460        best_scores = scores_mat[np.arange(len(u_keys)), best_idx]461 462        fuzzy_matched                = remaining_r.copy()463        fuzzy_matched['newmapping']  = fuzzy_matched['mapped']464        fuzzy_matched['mapped']      = mapped_lkp['mapped'].iloc[best_idx].values465        fuzzy_matched[mdm_col]       = mapped_lkp[mdm_col].iloc[best_idx].values466        fuzzy_matched['score']       = best_scores467 468        # Below 50 is noise — suppress to avoid polluting the analyst view.469        fuzzy_matched = fuzzy_matched[fuzzy_matched['score'] >= 50]470 471        # ── "All Other" fallback ──────────────────────────────────────────472        # If a standard catch-all bucket exists in the label space, any flat-473        # file key that still has no match (score < 50 for every candidate) is474        # assigned it with score=0.  This ensures every product appears in the475        # lookup table so Phase 2 can fill the cell rather than leaving it blank.476        # Score=0 guarantees QC Priority=HIGH in Ensemble so the analyst sees it.477        # Deduplicate to unique AO-style labels only — the same label will478        # appear once per historical key that uses it (e.g. 646× "AO BRAND"),479        # so iterating the raw column would give a list of length 646, not 1,480        # causing the single-AO-bucket guard to incorrectly suppress the fallback.481        ao_label_candidates = list({482            lbl for lbl in mapped_lkp[mdm_col].dropna().str.strip()483            if _AO_RE.match(lbl)484        })485        # Only apply the fallback when exactly one AO bucket exists.486        # Multiple distinct AO labels (e.g. "AO MAINSTREAM BRANDS" + "AO VALUE BRANDS")487        # are ambiguous — assigning the wrong bucket is worse than leaving488        # the cell blank for the analyst to handle.489        ao_label = ao_label_candidates[0] if len(ao_label_candidates) == 1 else None490        if ao_label is not None:491            already_matched_keys = set(fuzzy_matched['newmapping'].tolist()) if not fuzzy_matched.empty else set()492            fallback_rows        = remaining_r[~remaining_r['mapped'].isin(already_matched_keys)].copy()493            if not fallback_rows.empty:494                fallback_rows[mdm_col] = ao_label495                fallback_rows['score'] = 0496                print(f"    AO fallback: {len(fallback_rows)} unmatched key(s) → '{ao_label}'")497                fuzzy_matched = pd.concat([fuzzy_matched, fallback_rows], ignore_index=True)498 499        if not fuzzy_matched.empty:500            lookup_table = pd.concat([501                directly_matched[attr_key_cols + [mdm_col, 'score']],502                fuzzy_matched[attr_key_cols + [mdm_col, 'score']],503            ]).fillna(100).drop_duplicates()504        else:505            lookup_table = directly_matched[attr_key_cols + [mdm_col, 'score']]506 507        lookup_table[mdm_col]  = lookup_table[mdm_col].astype(str)508        directly_matched       = pd.concat([directly_matched, fuzzy_matched]).drop_duplicates()509 510    # ── Full-coverage guarantee ───────────────────────────────────────────────511    # Every key combo present in the flat file must have at least one lkp row,512    # even if the algorithm couldn't assign a value.  Without this, Phase 1513    # silently drops unresolvable key combos and Phase 2's left-join returns514    # NaN with no analyst visibility.  Blank-value rows (score=0) make the gap515    # explicit: the analyst sees them in the lkp, fills them in, and Phase 2516    # then picks up the corrected value — satisfying the design contract that517    # all MODELING attributes are filled before the pipeline continues.518    _ff_keys  = new_products_df[attr_key_cols].drop_duplicates()519    _lkp_keys = lookup_table[attr_key_cols].drop_duplicates()520    _missing  = pd.merge(_ff_keys, _lkp_keys, on=attr_key_cols, how='left', indicator=True)521    _missing  = _missing[_missing['_merge'] == 'left_only'].drop(columns=['_merge'])522    if not _missing.empty:523        _missing = _missing.copy()524        _missing[mdm_col] = ''525        _missing['score'] = 0526        # A coverage gap means key-column VALUE(S) in the new data were not present527        # in history, so Lookup can't carry a label across. It is NOT (usually) a528        # column name/alignment problem -- that would leave ~the whole attribute529        # unmatched, which the near-total note below calls out separately. Modeling530        # values aren't expected to change between refreshes, so an unseen value is531        # typically a data-entry slip (a placeholder/typo IN THE VALUE, e.g. "NA ...")532        # or a genuinely new value for ML + analyst QC. List the offending values533        # (repr() shows spacing/casing); cap the list so a large gap doesn't flood.534        _total  = len(_ff_keys)535        _frac   = len(_missing) / _total if _total else 1.0536        _combos = [537            ", ".join(f"{c}={v!r}" for c, v in zip(attr_key_cols, _row))538            for _row in _missing[attr_key_cols].astype(str).itertuples(index=False, name=None)539        ]540        _preview = _combos[:10]541        print(f"  WARNING - Coverage gap in attribute '{mdm_col}': {len(_missing)} of "542              f"{_total} key combination(s) in the new data are not in the historical data, "543              f"so they were written blank (flagged HIGH for QC). These are unseen values in "544              f"({', '.join(attr_key_cols)}); modeling values are not expected to change "545              f"between refreshes, so review each as a data-entry slip in the value or a "546              f"genuinely new value for ML/analyst assignment:")547        for _c in _preview:548            print(f"      - {_c}")549        if len(_combos) > len(_preview):550            print(f"      ... and {len(_combos) - len(_preview)} more "551                  f"(see the '{mdm_col}' lookup sheet -- blank rows with score 0)")552        if _frac >= 0.99:553            print(f"      NOTE: nearly ALL key combos for '{mdm_col}' are unmatched -- also "554                  f"check the key columns line up with history (names/structure), not just values.")555        lookup_table = pd.concat(556            [lookup_table, _missing[attr_key_cols + [mdm_col, 'score']]],557            ignore_index=True,558        )559 560    lookup_table['flag'] = 1561    lookup_table['Rank'] = (562        lookup_table[attr_key_cols + ['flag']]563        .groupby(attr_key_cols)['flag']564        .rank(method='dense', ascending=False)565    )566    lookup_table.drop(columns=['flag'], inplace=True)567    lookup_table = pd.merge(568        lookup_table,569        lookup_table[attr_key_cols + [mdm_col]]570            .groupby(attr_key_cols).agg(Record=(mdm_col, 'count')).reset_index(),571        on=attr_key_cols,572    )573    lookup_table.replace({'missing': ''}, regex=True, inplace=True)574    return directly_matched, lookup_table575 576 577# ── Public entry point ────────────────────────────────────────────────────────578 579def runLookup(flat_file_df: pd.DataFrame, meta_df: pd.DataFrame,580              history_df: pd.DataFrame, recom_dict: dict) -> tuple:581    """582    Run the full Lookup stage for all MODELING attributes defined in META.583 584    Parameters585    ----------586    flat_file_df : DataFrame587        New products to classify (the flat-file CSV, string-coerced).588    meta_df : DataFrame589        META sheet — defines attribute groups and their key columns.590    history_df : DataFrame591        Historical FINAL data — the labelled training reference.592    recom_dict : dict593        Accumulator for all predictor outputs.  Lookup results are added594        as "Lookup_{attrG}" keys.595 596    Returns597    -------598    (recom_dict, history_df, flat_file_output, flag_map)599    """600    base_results, historical_agg, fuzzy_matches, flat_file_combos, attr_key_map, flag_map = (601        _build_attribute_table(flat_file_df, meta_df, history_df)602    )603    top_matches      = _select_top_matches(fuzzy_matches)604    flat_file_output, _ = _build_flat_file_output(flat_file_df, meta_df)605 606    for mdm_col in historical_agg:607        try:608            _, recom_dict[f'Lookup_{mdm_col}'] = _build_lookup_table(609                top_matches[mdm_col], flat_file_combos[mdm_col], attr_key_map[mdm_col], mdm_col610            )611        except Exception as exc:612            print(f"  ERROR: Lookup failed for '{mdm_col}': {exc}")613            print(traceback.format_exc())614 615    return recom_dict, history_df, flat_file_output, flag_map616