Repository navigation
fix(validation): catch bad input at the public boundary with specific error messages #592
Description
Noticed while watching coding agents work from the installed package. These errors come from 240 agent runs over 15 retail-analysis tasks, plus a source audit. Checked on main @ c5f4b2a; every message below was reproduced.
What: when a caller passes the wrong input, ORS often doesn't catch it. The mistake surfaces later as an ibis, DuckDB or pandas error that names internals rather than the cause, or as an ORS message that states a rule but not what was passed or how to fix it. Each one cost the agents retries. The worst took 6 to 17 turns, and some runs never recovered.
Errors seen in the runs, ranked by runs affected:
| Error the caller sees | Runs | Real mistake | Where to catch it | Suggested message |
|---|---|---|---|---|
ValueError: Unknown option: transaction_date (also column, column.category_0_name, store_id) |
18 | The option name is missing its column. prefix, or doesn't exist |
options.py:287, 307, 320 | Unknown option 'transaction_date'. Did you mean 'column.transaction_date'? See list_options(). (difflib.get_close_matches) |
AttributeError: 'StringColumn' object has no attribute 'truncate' |
15 | transaction_date is a string, e.g. straight from read_csv |
cohort.py:174, with no dtype check | transaction_date must be a date or datetime column, got string. Convert it with pd.to_datetime first. |
ValueError: The number of thresholds must match the number of segments |
14 | thresholds=[0.5, 0.8] for 3 segments; the last threshold must be 1.0 |
threshold.py:65-66 | Got 2 thresholds for 3 segments. thresholds are ascending percentile upper bounds, one per segment, ending at 1.0, e.g. [0.5, 0.8, 1.0]. |
ValueError: The name customer_id occurs multiple times, use a level number |
6 | GainLoss(group_col="customer_id"); the class already groups by customer |
gain_loss.py:76-78 | group_col must not be the customer column; GainLoss already groups by customer. |
KeyError: 'customers_p2', or ValueError: Length mismatch: Expected axis has 1 elements, new values have 2 elements |
7 | p1_value/p2_value matches no row of period_col (wrong format or type, or a raw date column) |
revenue_tree.py:279-313 | p2_value='2023-Q3' matches no rows of period_col 'quarter'. Values present: '2023Q1', '2023Q2', … |
IbisInputError: Duplicate column name 'customer_id' in result set |
4 | ThresholdSegmentation(group_col="customer_id") |
threshold.py:72-85 | group_col must not include the customer column; segmentation is already per customer. |
TypeError: Unable to normalize 'Total' to Int64(nullable=True) |
3 | SegTransactionStats on an integer segment_col: the default rollup_value="Total" can't be cast to it |
segstats.py:379 | rollup_value 'Total' can't be stored in integer column 'customer_id'. Pass calc_total=False, a numeric rollup_value, or cast the column to str. |
Columns ['Skincare'] in grouping_sets not found in segment_col; Each element must be a tuple. Got: str |
3 | Segment values or bare strings passed in place of tuples of column names | segstats.py:440, 568-571 | Add the form: grouping_sets entries are tuples of segment_col names, e.g. [("category_1_name",), ()], or "total"/"rollup"/"cube". |
AttributeError: module 'ibis.expr.datatypes' has no attribute 'Period' |
2 | A pandas period[M] column |
validation.py:57 (ibis.memtable) |
Column 'month' has dtype period[M], which isn't supported. Convert it with .astype(str) or .dt.to_timestamp(). |
ValueError: p1_index and p2_index should not overlap |
1 (17 turns) | df indexed by a repeated customer_id; the masks themselves are disjoint |
gain_loss.py:65-69 compares index labels | Check the masks with (p1_index & p2_index).any(), and require a unique index: df.index must be unique; call df.reset_index() first. |
ConversionException: Could not convert string 'La Roche-Posay' to INT64; target_item must contain only str or float values. Got numpy.int64 |
1 | target_item doesn't match value_col's type, and int ids are rejected |
product_association.py:163 | Accept ints. Otherwise say target_item 'La Roche-Posay' doesn't match value_col 'product_id' (int64). |
Found in the source audit, reproduced:
-
Wrong
agg_funcname. It is checked inNLRSegmentationandCompositeRankonly.CohortAnalysis,ThresholdSegmentation,CrossShopandGainLossgive a rawAttributeError: 'FloatingColumn' object has no attribute 'summ'. -
Non-date
transaction_dateinRFMSegmentation.- Integer dates dump SQL:
ConversionException: ... (BIGINT -> DATE) ... LINE 1: ...make_date(2024, 12, 31)) AS INTEGER) AS recency_days. - ISO strings silently work.
- Only
customer.pycallsensure_tznaive_datetime.
- Integer dates dump SQL:
-
RFMSegmentation(current_date="31/12/2024")givesValueError: Invalid isoformat string: '31/12/2024', which doesn't namecurrent_date. -
GainLossmask length. The length check runs after the masks are used, so it never fires. A short list givesItem wrong length 59 instead of 60. -
SegTransactionStatswith a stringunit_spendgives'StringColumn' object has no attribute 'sum'. -
ThresholdSegmentationinput it doesn't check, accepted silently:- thresholds that don't end at 1.0 leave the top customers with segment
None; - unsorted thresholds put everyone in one segment;
- a value above 1 is accepted;
- a typo in
zero_value_customersis treated asinclude_with_light.
- thresholds that don't end at 1.0 leave the top customers with segment
-
Rules without the value passed.
product_association.pyL108-116: "Minimum support must be between 0 and 1".rfm.pyL156-189: "min_frequency must be an integer".customer_decision_hierarchy.pyL125, L129, L210.cross_shop.pyL92, L155.
These should add
Got {value!r}. Theensure_*helpers incore/validation.pyalready do this. -
CohortAnalysisgiven a non-DataFrame fails with'list' object has no attribute 'columns', because it checks columns beforeensure_ibis_table. -
option_context("column.customer_id")says "requires an even number of arguments" without saying how many were passed.
Ask: validate at the public boundary, in a few shared helpers, so every class gets the same messages:
ensure_date_column(df, col): date or datetime, with thepd.to_datetimeremedy. Call it from every class that readstransaction_date:CohortAnalysis,RFMSegmentation,CLVStats, … (ensure_tznaive_datetimealready has the message).ensure_agg_func(table, col, func): reuse the probe fromensure_valid_extra_aggs. Name the parameter, the value and the valid options.- A guard that
group_coldoesn't include the customer column, for the classes that already group by customer. - A check that
p1_value/p2_value(andtarget_item) appear in the data, echoing sample values. - One raise site for unknown options, with a did-you-mean suggestion.
ensure_ibis_tablefirst in every constructor.- Every rule message names the parameter and the value passed.
One parametrized test per helper, over every class that uses it, keeps new classes covered.
Related: #536 (NULL/NaT dates), #590 (exports), #591 (docstring contracts).
Activity
murray-ds commentedon Oct 22, 2026
Failing tests for the error-message cases above. All 12 fail on main @ c5f4b2a.
The two
RFMSegmentationdate cases don't raise at construction at all: the DuckDBConversionExceptiononly appears when.dfis read, which is a further reason to validate at the boundary.The bugs these checks turned up are filed separately: #593, #594, #595, #600, #601. The suspected
SegTransactionStatsfailure with no customer column did not reproduce: without a customer column it works and drops the customer metrics. Only an integersegment_colwith the defaultrollup_valuefails.fix(validation)!: catch bad input at the public boundary with specifi…