SPB Git

spb/wp38_uqo

Public

Label namespaces (A-002), hypothesis registry, manuscript sections (lit, setting, data, hypotheses, methods), bib fixes

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Nov 1, 2026) parent 1a9fe87 Browse files

57 changed files +510 −349

57 changed files
Unified Split
added experiments/hypothesis_registry_rule.json +93 −0
@@ -0,0 +1,93 @@
1 +{
2 + "labelling": "rule",
3 + "holm_family": [
4 + "H1",
5 + "H2",
6 + "H3",
7 + "H5-T1",
8 + "H5-T2"
9 + ],
10 + "registry": [
11 + {
12 + "id": "H1",
13 + "statement": "Online assortment composition differs across regions beyond what merchant counts imply",
14 + "primary_statistic": "Cramér's V (M) = 0.105, store-reshuffle p = 0.0005; (T) V = 0.159, p = 0.492; (L) p = 0.058",
15 + "p_raw": 0.49225387306346824,
16 + "p_holm": 0.9434166539280238,
17 + "status": "partially supported",
18 + "note": "Preregistered criterion required p < 0.05 under both merchant-equal and trimmed-listing weights; met for merchant-equal only. Specialization is a property of the merchant population, not of listing volumes."
19 + },
20 + {
21 + "id": "H2",
22 + "statement": "Regional diversity is associated with regional scale",
23 + "primary_statistic": "Spearman rho(rarefied entropy, log population) = -0.20, 95% CI [-0.62; 0.34], n = 16",
24 + "p_raw": 0.4717083269640119,
25 + "p_holm": 0.9434166539280238,
26 + "status": "inconclusive",
27 + "note": "CI includes zero and economically meaningful values in both directions; n = 16 regions. No association with business counts or density either (see table)."
28 + },
29 + {
30 + "id": "H3",
31 + "statement": "Merchant catalogue sizes are highly skewed",
32 + "primary_statistic": "Gini = 0.849 [0.811; 0.878]; top-10% share = 0.789 [0.730; 0.836]",
33 + "p_raw": null,
34 + "p_holm": null,
35 + "status": "supported",
36 + "note": "Both preregistered thresholds (Gini >= 0.70, top-10% >= 0.50) exceeded with CIs entirely above them; power-law tail favoured over log-normal on the tail."
37 + },
38 + {
39 + "id": "H4",
40 + "statement": "Related diversification (exploratory)",
41 + "primary_statistic": "LPM density coefficient 0.111 (SE 0.050), placebo p = 0.010; 149 entries; 11 region-level entries",
42 + "p_raw": null,
43 + "p_holm": null,
44 + "status": "exploratory: consistent with relatedness at the store level; region level untestable",
45 + "note": "Fewer than 10 region-level entries (preregistered untestability rule); store-level association over 5 weeks, no causal claim."
46 + },
47 + {
48 + "id": "H5-T1",
49 + "statement": "Matched-product prices are geographically structured",
50 + "primary_statistic": "Wald(14) = 164.4, wild-cluster-bootstrap p = 0.0045; 1064 groups, 143 stores, 9 regions with >= 5 stores",
51 + "p_raw": 0.0045,
52 + "p_holm": 0.013499999999999998,
53 + "status": "supported",
54 + "note": "Region effects identified only through groups spanning regions; posted prices, no shipping/taxes."
55 + },
56 + {
57 + "id": "H5-T2",
58 + "statement": "Matched-product prices are competitively structured (reseller vs producer)",
59 + "primary_statistic": "reseller coefficient -0.116 (SE 0.026), wild-bootstrap p = 0.0012",
60 + "p_raw": 0.0012,
61 + "p_holm": 0.0048,
62 + "status": "supported",
63 + "note": "Resellers/collectives post lower prices than producers for the same product."
64 + },
65 + {
66 + "id": "H6",
67 + "statement": "Local-origin price premium",
68 + "primary_statistic": "not estimated",
69 + "p_raw": null,
70 + "p_holm": null,
71 + "status": "untestable (declared ex ante)",
72 + "note": "No product-level provenance; a store-certification contrast is reported descriptively and is not H6."
73 + },
74 + {
75 + "id": "H7",
76 + "statement": "Validated digital-assortment complexity indicator",
77 + "primary_statistic": "ECI bootstrap rank stability median rho = 0.20 (criterion 0.8); lambda2 null p = 0.41; LOO predictive rho = 0.39",
78 + "p_raw": null,
79 + "p_holm": null,
80 + "status": "not validated: ECI unstable at 17 x 20; omitted from QDACI",
81 + "note": "Fitness and plain diversity are more stable but still below the criterion; the region-category matrix is not more nested than the store-reshuffle null."
82 + },
83 + {
84 + "id": "QDACI",
85 + "statement": "Composite index",
86 + "primary_statistic": "median agreement across weightings rho = 0.68 (rule 0.7); bootstrap rank stability 0.50",
87 + "p_raw": null,
88 + "p_holm": null,
89 + "status": "components published; composite withheld",
90 + "note": "Components describe observed online catalogues of Quebec-based merchants, not production or output. Complexity omitted because the ECI failed the preregistered stability criterion."
91 + }
92 + ]
93 +}
\ No newline at end of file
modified literature/references.bib +4 −4
@@ -29,7 +29,7 @@
29 29 }
30 30
31 31 @techreport{atn_netendances,
32 − author = {{Académie de la transformation numérique}},
32 + author = {{Acad{\'e}mie de la transformation num{\'e}rique}},
33 33 title = {{NETendances} --- enquêtes sur les usages numériques des Québécois},
34 34 year = {n.d.},
35 35 institution = {Académie de la transformation numérique, Université Laval, Québec},
@@ -850,7 +850,7 @@
850 850 }
851 851
852 852 @article{hidalgo2007,
853 − author = {Hidalgo, C. A. and Klinger, B. and Barabási, A.-L. and Hausmann, R.},
853 + author = {Hidalgo, C. A. and Klinger, B. and Barabási, A.-L. and Hausmann, R.},
854 854 title = {The Product Space Conditions the Development of Nations},
855 855 year = {2007},
856 856 journal = {Science},
@@ -1165,7 +1165,7 @@
1165 1165 }
1166 1166
1167 1167 @book{oecd2019,
1168 − author = {,},
1168 + author = {{OECD}},
1169 1169 title = {Unpacking E-commerce},
1170 1170 year = {2019},
1171 1171 publisher = {OECD Publishing},
@@ -1449,7 +1449,7 @@
1449 1449
1450 1450 @article{whitacre2014,
1451 1451 author = {Whitacre, Brian and Gallardo, Roberto and Strover, Sharon},
1452 − title = {Broadband׳s contribution to economic growth in rural areas: Moving towards a causal relationship},
1452 + title = {Broadband's contribution to economic growth in rural areas: Moving towards a causal relationship},
1453 1453 year = {2014},
1454 1454 journal = {Telecommunications Policy},
1455 1455 volume = {38},
modified paper/appendix/appendix.tex +13 −86
@@ -1,93 +1,20 @@
1 −% ============================================================================
2 −% Appendix
3 −% ============================================================================
4 −\newpage
5 1 \begin{appendices}
2 +\section{Source audit and compliance}\label{app:source}
3 +The Phase~0 source audit (\texttt{reports/00\_source\_audit.md}) documents the three tables kept from the snapshot (\texttt{stores}, 4{,}082 rows; \texttt{products}, 465{,}388; \texttt{sync\_log}, 577{,}773), their fields, missingness and the platform's own compliance review per source: public catalogue endpoints only, polite rates, nothing behind authentication, retained attribution and removal on request. The research used no scraping of the platform and a single control request to its public statistics endpoint to date-stamp the counters.
6 4
7 −\renewcommand{\thetable}{A\arabic{table}}
8 −\renewcommand{\thefigure}{A\arabic{figure}}
9 −\setcounter{table}{0}
10 −\setcounter{figure}{0}
5 +\section{Data dictionary}\label{app:dictionary}
6 +The full field-level dictionary of the cleaned tables (\texttt{listings\_clean}, \texttt{stores\_master}, \texttt{sibling\_stores}, \texttt{match\_pairs}, \texttt{match\_groups}, \texttt{sync\_log}) is generated by the pipeline (\texttt{reports/data\_dictionary.md}). Key derived fields: \texttt{title\_norm} (ASCII-folded, lower-cased title used for duplicate detection and matching); \texttt{f\_analysis\_assort} and \texttt{f\_analysis\_price} (sample membership flags); \texttt{region\_final} and \texttt{region\_source} (assigned region and its provenance: municipality lookup, platform label, FSA-centroid point-in-polygon, or unknown); \texttt{category\_model} (classifier label, Amendment A-002); \texttt{comparable} (variant-structure comparability of a matched pair).
11 7
12 −\section{Additional Tables and Figures}
13 −\label{app:tables}
8 +\section{Category taxonomy and audit details}\label{app:categories}
9 +\input{tables/category_audit}
14 10
15 −% --- Appendix Table: Sub-sample Analysis ---
16 −\begin{table}[htbp]
17 − \centering
18 − \begin{threeparttable}
19 − \caption{Sub-sample Analysis}
20 − \label{tab:app_subsample}
21 − \begin{tabular}{@{}lccc@{}}
22 − \toprule
23 − & (1) & (2) & (3) \\
24 − & Full Sample & Pre-2008 & Post-2008 \\
25 − \midrule
26 − $X_{i,t}$ & 0.172$^{***}$ & 0.195$^{***}$ & 0.148$^{***}$ \\
27 − & (0.033) & (0.048) & (0.041) \\[6pt]
28 − \midrule
29 − Controls & Yes & Yes & Yes \\
30 − Firm FE & Yes & Yes & Yes \\
31 − Time FE & Yes & Yes & Yes \\[3pt]
32 − Observations & 125{,}400 & 58{,}200 & 67{,}200 \\
33 − Adjusted $R^2$ & 0.190 & 0.175 & 0.203 \\
34 − \bottomrule
35 − \end{tabular}
36 − \begin{tablenotes}[flushleft]
37 − \footnotesize
38 − \item \textit{Notes:} This table reports sub-sample results. Column~(1) reproduces the baseline estimate from Table~\ref{tab:regression}, Column~(4). Columns~(2) and~(3) split the sample before and after January~2008. All specifications include the full set of controls and firm and time fixed effects. Standard errors clustered at the firm level are in parentheses. $^{***}$, $^{**}$, and $^{*}$ denote significance at the 1\%, 5\%, and 10\% levels.
39 − \end{tablenotes}
40 − \end{threeparttable}
41 −\end{table}
11 +\section{Within-region concentration}\label{app:concentration}
12 +\input{tables/h3_within_region}
42 13
43 −% --- Appendix Table: Robustness Checks ---
44 −\begin{table}[htbp]
45 − \centering
46 − \begin{threeparttable}
47 − \caption{Robustness Checks}
48 − \label{tab:app_robustness}
49 − \begin{tabular}{@{}lccc@{}}
50 − \toprule
51 − & (1) & (2) & (3) \\
52 − & Alt.\ Dep.\ Var. & Alt.\ Controls & Winsorized 5\% \\
53 − \midrule
54 − $X_{i,t}$ & 0.165$^{***}$ & 0.178$^{***}$ & 0.169$^{***}$ \\
55 − & (0.036) & (0.034) & (0.032) \\[6pt]
56 − \midrule
57 − Controls & Yes & Yes & Yes \\
58 − Firm FE & Yes & Yes & Yes \\
59 − Time FE & Yes & Yes & Yes \\[3pt]
60 − Observations & 125{,}400 & 125{,}400 & 119{,}800 \\
61 − Adjusted $R^2$ & 0.185 & 0.192 & 0.195 \\
62 − \bottomrule
63 − \end{tabular}
64 − \begin{tablenotes}[flushleft]
65 − \footnotesize
66 − \item \textit{Notes:} This table reports robustness checks. Column~(1) uses an alternative dependent variable. Column~(2) includes additional control variables. Column~(3) winsorizes all continuous variables at the 5th and 95th percentiles. All specifications include firm and time fixed effects. Standard errors clustered at the firm level are in parentheses. $^{***}$, $^{**}$, and $^{*}$ denote significance at the 1\%, 5\%, and 10\% levels.
67 − \end{tablenotes}
68 − \end{threeparttable}
69 −\end{table}
70 −
71 −\section{Variable Definitions}
72 −\label{app:variables}
73 −
74 −\begin{table}[htbp]
75 − \centering
76 − \caption{Variable Definitions}
77 − \label{tab:app_variables}
78 − \begin{tabular}{@{}p{3.5cm}p{10cm}@{}}
79 − \toprule
80 − Variable & Definition \\
81 − \midrule
82 − $r_{i,t}$ & Monthly log return of firm $i$ in month $t$, computed as $\ln(P_{i,t}/P_{i,t-1})$. \\[4pt]
83 − $\sigma_{i,t}$ & Monthly realized volatility of firm $i$, computed as the standard deviation of daily returns within month $t$, annualized by multiplying by $\sqrt{252}$. \\[4pt]
84 − $X_{i,t}$ & Main explanatory variable. [Provide specific definition.] \\[4pt]
85 − Size & Natural logarithm of market capitalization (in millions of USD) at the end of month $t-1$. \\[4pt]
86 − Book-to-Market & Ratio of book equity to market equity, using the most recent fiscal year-end book value and the market value at the end of June. Updated annually following \citet{fama1993common}. \\[4pt]
87 − Leverage & Total debt divided by total assets, measured at the most recent fiscal year-end. \\[4pt]
88 − ROA & Return on assets, defined as net income divided by total assets. \\
89 − \bottomrule
90 − \end{tabular}
91 −\end{table}
14 +\section{Region-level indicators}\label{app:indicators}
15 +\input{tables/h7_indicators}
16 +\input{tables/qdaci}
92 17
18 +\section{Preregistration, decision log and amendments}\label{app:prereg}
19 +The frozen preregistration (\texttt{research/preregistration.md}), the decision log (\texttt{research/decisions.md}, D-001 to D-007) and the two amendments are reproduced in the repository. A-001 (region assignment) replaced ``platform label first'' by ``official geography first'' after a first attempt with the Statistics Canada 2021 economic-region layer produced spurious Estrie/Montérégie conflicts that predate the 2021 boundary change; the final rule uses the MRNF municipality table for urban forward sortation areas, then the platform label, then the FSA-centroid point-in-polygon. A-002 (category labels) introduced a supervised classifier after the audit measured the keyword rules at 46\% precision, with the primacy rule stated before any result was computed under the new labels.
93 20 \end{appendices}
modified paper/figures/attrition.pdf +0 −0

Binary file not shown.

modified paper/figures/category_network.pdf +0 −0

Binary file not shown.

modified paper/figures/coverage_map.pdf +0 −0

Binary file not shown.

modified paper/figures/diversity_region.pdf +0 −0

Binary file not shown.

modified paper/figures/h4_density.pdf +0 −0

Binary file not shown.

modified paper/figures/h5_region_effects.pdf +0 −0

Binary file not shown.

modified paper/figures/h7_validation.pdf +0 −0

Binary file not shown.

modified paper/figures/price_gaps.pdf +0 −0

Binary file not shown.

modified paper/figures/raa_heatmap.pdf +0 −0

Binary file not shown.

modified paper/figures/rank_size.pdf +0 −0

Binary file not shown.

modified paper/figures/robustness_h1.pdf +0 −0

Binary file not shown.

added paper/main.pdf +0 −0

Binary file not shown.

modified paper/main.tex +13 −22
@@ -42,6 +42,7 @@
42 42 \usepackage{graphicx}
43 43 \usepackage{subcaption}
44 44 \usepackage{float}
45 +\usepackage{adjustbox}
45 46
46 47 % --- Colors & Links ---
47 48 \usepackage[dvipsnames]{xcolor}
@@ -89,19 +90,14 @@
89 90 % ============================================================================
90 91 % METADATA — Edit these fields for each working paper
91 92 % ============================================================================
92 −\newcommand{\WPnumber}{XX}
93 −\newcommand{\WPtitle}{Your Working Paper Title Here}
94 −\newcommand{\WPsubtitle}{} % Leave empty if no subtitle
95 −\newcommand{\WPdate}{\today}
93 +\newcommand{\WPnumber}{38}
94 +\newcommand{\WPtitle}{The Economic Geography of Digital Production: Product Diversity, Regional Specialization, and the Hidden Structure of Quebec's Online Economy}
95 +\newcommand{\WPsubtitle}{Evidence from 390{,}108 product listings of 1{,}569 Quebec online stores across 17 administrative regions (Fabri-Ka snapshot, 1 November 2026)}
96 +\newcommand{\WPdate}{November 2026}
96 97 \newcommand{\WPversion}{1.0}
97 −\newcommand{\WPabstract}{%
98 − This paper examines \ldots{}
99 − We find that \ldots{}
100 − Our results suggest \ldots{}
101 − These findings contribute to the literature on \ldots{}
102 −}
103 −\newcommand{\WPkeywords}{Keyword 1, Keyword 2, Keyword 3, Keyword 4}
104 −\newcommand{\WPjel}{G12, G14, C58}
98 +\input{sections/abstract}
99 +\newcommand{\WPkeywords}{economic geography; online retail; product assortment; revealed comparative advantage; diversity; economic complexity; price dispersion; web-scraped data; Quebec}
100 +\newcommand{\WPjel}{R12, L81, O18, C81, D22, R32}
105 101
106 102 % --- Author ---
107 103 \newcommand{\WPauthor}{Simon-Pierre Boucher}
@@ -120,24 +116,19 @@
120 116 % DOCUMENT
121 117 % ============================================================================
122 118 \begin{document}
123 −
124 −% --- Title Page ---
125 119 \input{sections/titlepage}
126 −
127 −% --- Main Body ---
128 120 \input{sections/introduction}
129 121 \input{sections/literature}
122 +\input{sections/institutional}
130 123 \input{sections/data}
131 −\input{sections/methodology}
124 +\input{sections/hypotheses}
125 +\input{sections/methods}
132 126 \input{sections/results}
133 127 \input{sections/robustness}
128 +\input{sections/discussion}
134 129 \input{sections/conclusion}
135 −
136 −% --- References ---
130 +\input{sections/reproducibility}
137 131 \newpage
138 132 \bibliography{references}
139 −
140 −% --- Appendix ---
141 133 \input{appendix/appendix}
142 −
143 134 \end{document}
modified paper/references.bib +4 −4
@@ -29,7 +29,7 @@
29 29 }
30 30
31 31 @techreport{atn_netendances,
32 − author = {{Académie de la transformation numérique}},
32 + author = {{Acad{\'e}mie de la transformation num{\'e}rique}},
33 33 title = {{NETendances} --- enquêtes sur les usages numériques des Québécois},
34 34 year = {n.d.},
35 35 institution = {Académie de la transformation numérique, Université Laval, Québec},
@@ -850,7 +850,7 @@
850 850 }
851 851
852 852 @article{hidalgo2007,
853 − author = {Hidalgo, C. A. and Klinger, B. and Barabási, A.-L. and Hausmann, R.},
853 + author = {Hidalgo, C. A. and Klinger, B. and Barabási, A.-L. and Hausmann, R.},
854 854 title = {The Product Space Conditions the Development of Nations},
855 855 year = {2007},
856 856 journal = {Science},
@@ -1165,7 +1165,7 @@
1165 1165 }
1166 1166
1167 1167 @book{oecd2019,
1168 − author = {,},
1168 + author = {{OECD}},
1169 1169 title = {Unpacking E-commerce},
1170 1170 year = {2019},
1171 1171 publisher = {OECD Publishing},
@@ -1449,7 +1449,7 @@
1449 1449
1450 1450 @article{whitacre2014,
1451 1451 author = {Whitacre, Brian and Gallardo, Roberto and Strover, Sharon},
1452 − title = {Broadband׳s contribution to economic growth in rural areas: Moving towards a causal relationship},
1452 + title = {Broadband's contribution to economic growth in rural areas: Moving towards a causal relationship},
1453 1453 year = {2014},
1454 1454 journal = {Telecommunications Policy},
1455 1455 volume = {38},
added paper/sections/abstract.tex +1 −0
@@ -0,0 +1 @@
1 +\newcommand{\WPabstract}{Abstract to be written after the final run.}
modified paper/sections/conclusion.tex +2 −12
@@ -1,12 +1,2 @@
1 −% ============================================================================
2 −\section{Conclusion}
3 −\label{sec:conclusion}
4 −% ============================================================================
5 −
6 −This paper investigates \ldots{} Using data from \ldots{} over the period \ldots{}, we find that \ldots{}
7 −
8 −Our results contribute to the literature in several ways. First, \ldots{} Second, \ldots{} Third, \ldots{}
9 −
10 −These findings have important implications for \ldots{} From a policy perspective, \ldots{} For practitioners, \ldots{}
11 −
12 −Several limitations of the present study suggest directions for future research. First, \ldots{} Second, \ldots{} We leave these questions for future work.
1 +\section{conclusion}\label{sec:conclusion}
2 +TODO.
modified paper/sections/data.tex +20 −23
@@ -1,32 +1,29 @@
1 −% ============================================================================
2 −\section{Data}
3 −\label{sec:data}
4 −% ============================================================================
1 +\section{Data: snapshot, cleaning, provenance and representativeness}\label{sec:data}
5 2
6 −\subsection{Data Sources}
7 −\label{subsec:data_sources}
3 +\subsection{Snapshot and samples}
4 +The research copy is a read-only backup of the Fabri-Ka production database taken on 1 November 2026 at 17:38 UTC (application commit \texttt{689c7bb}); the only tables containing personal data (two user accounts, one self-registration) and the derived full-text index were dropped on the production node before the copy left it. The copy holds 465{,}388 product rows, of which 390{,}108 are active and published by the platform's own rules --- gift cards, workshops, subscriptions, tickets and price-out-of-bounds quarantines are excluded upstream --- in 1{,}569 enabled stores, plus 577{,}773 synchronisation events. Table~\ref{tab:sample_flow} and Figure~\ref{fig:attrition} trace the path to the two analysis samples. We drop one store flagged out of mission by the platform, keep one representative listing per (store, normalised title) because many merchants publish colour or format variants as separate products (19{,}245 non-representative listings), and remove eleven \emph{mirror domains} --- language or top-level-domain twins of the same business, detected by domain stem or by a title overlap of at least 90\% of the smaller catalogue --- which would double-count assortments and manufacture within-firm ``cross-merchant'' price pairs. The \textbf{assortment sample} has 370{,}054 listings in 1{,}557 stores and twenty categories. The \textbf{price sample} further requires a Canadian-dollar store and listing, a positive posted price not ``on request'' and at most 50{,}000~CAD (9{,}635 listings have no posted price, 729 are in foreign currency, 78 exceed the cap): 359{,}612 listings. Within the assortment sample, 13.1\% of listings are marked sold out by the merchant, 8.7\% carry a strike-through price, 27.8\% have several variants, 76.7\% carry a merchant SKU and 75.6\% a brand field.
8 5
9 −We obtain data from \ldots{} Our sample spans the period from January~1990 to December~2023, covering \ldots{} We exclude \ldots{} consistent with standard practice in the literature \citep{fama1993common}.
6 +\input{tables/sample_flow}
10 7
11 −\subsection{Variable Construction}
12 −\label{subsec:variables}
8 +\subsection{Merchant geography}\label{sec:data_geo}
9 +The platform assigns a region from the discovery source (regional directories), from region mentions on the site, or from a map of forward sortation areas (FSA, the first three characters of the postal code); coordinates are FSA centroids obtained from a public geocoder, never civic addresses. At the snapshot 346 of the 1{,}569 live stores had no region, including the four largest catalogues (a book retailer, a souvenir printer, a trailer-parts seller and a pet-supply retailer). We recovered regions from official geography: for urban FSAs the sector label is matched to the MRNF municipality table (393 stores, 95.9\% agreement with the point-in-polygon of the centroid where both exist), then the platform's own label is kept (865 stores), then the FSA centroid is located in the MRNF administrative-region polygons (82 stores). The order was amended after preregistration (A-001): a first attempt with the Statistics Canada 2021 economic-region layer produced 229 apparent conflicts between platform labels and polygons, of which 99 were a spelling artefact and most of the rest the Brome-Missisquoi/Haute-Yamaska transfer from Montérégie to Estrie that the 2021 layer predates; with the current MRNF layer the platform label and the centroid agree for 89.1\% of stores, and the remaining disagreements (30 after the final rule) are rural FSAs whose centroids fall near a border, which is why the platform label is preferred to the centroid. After recovery, 229 stores (71{,}692 listings, 19\% of the assortment sample) remain without a location; they come mainly from gift directories and search sweeps, are reported as a separate ``Inconnue'' row in descriptive tables, and are excluded from all regional inference. One further caveat: the telephone field of 250 stores is the switchboard of the certification directory that listed them (212 carry the number of Les Produits du Québec); telephones are therefore never used for geography. Table~\ref{tab:region_summary} summarises the regional sample.
13 10
14 −Our dependent variable is \ldots{} defined as:
15 −%
16 −\begin{equation}
17 − r_{i,t} = \ln\!\left(\frac{P_{i,t}}{P_{i,t-1}}\right),
18 − \label{eq:returns}
19 −\end{equation}
20 −%
21 −where $P_{i,t}$ denotes the adjusted closing price of asset~$i$ at time~$t$.
11 +\input{tables/region_summary}
22 12
23 −The main explanatory variables include \ldots{}
13 +\subsection{Category labels: audit and classifier (Amendment A-002)}\label{sec:data_categories}
14 +The platform assigns one of twenty categories by keyword rules on the merchant's product type, tags, title and option names. We audited these labels on a stratified random sample of 500 listings (25 per category) annotated independently by two large language models (claude-opus-5-5 and gpt-5.5) given the platform's own taxonomy definitions; the two annotators agree with each other on 94.8\% of listings but with the rule label on only 45.2\% and 44.6\% respectively, so the precision of the rule label against annotator consensus is 0.46 (Table~\ref{tab:cat_audit}). Errors are structured: the ``other'' bin is almost entirely resolvable into industrial, art or home goods; ``art'' absorbs industrial and craft supplies; beverages, alcohol, pets and outdoor goods are frequently mislabelled fashion or art when a merchant's tags are generic. Jewellery, garden and fashion are the most reliable rule labels.
24 15
25 −\subsection{Summary Statistics}
26 −\label{subsec:summary_stats}
16 +%%CLASSIFIER%%
27 17
28 −Table~\ref{tab:summary_stats} presents the descriptive statistics for our key variables.
18 +\subsection{Cross-merchant matching: candidates and validation}\label{sec:data_matching}
19 +Tier-A (shared SKU) and tier-B (same brand, identical title) candidates number 1{,}837 and 457 pairs in different stores, of which 1{,}683 and 381 have a comparable variant structure; fuzzy tier C adds 446 (324 comparable) and identical-title-only tier D 2{,}713. On a validation sample of 60 pairs per tier, two independent annotators agree on 87.5\% of pairs; among consensus pairs the share judged the same product is 0.92 for tier A, 0.90 for tier B, 0.77 for tier C and 0.17 for tier D (Table~\ref{tab:match_val}); restricted to comparable-variant pairs, tiers A and B reach 0.90 and 0.96. These are the precision figures we carry into the hedonic analysis; the tier-D figure confirms that identical titles across artisanal shops (``savon à la lavande'') are not the same product.
29 20
30 −\input{tables/summary_stats}
21 +\input{tables/match_validation}
31 22
32 −Several patterns emerge from the summary statistics. First, \ldots{} Second, \ldots{} The standard deviations indicate \ldots{}
23 +\subsection{External data}\label{sec:data_external}
24 +Regional covariates and geography were downloaded from their official sources with a manifest of URLs, licences, vintages and checksums (Appendix~\ref{app:source}): ISQ population at 1 July 2025, ISQ business locations with employees in December 2025 (all industries, retail NAICS 44--45, manufacturing 31--33), ISQ disposable income per capita 2024 and regional GDP 2024, Statistics Canada Census 2021 land area and density, the MRNF administrative-region and municipality layers (version 2026-10) and the Statistics Canada 2021 FSA boundaries. No recent regional (as opposed to provincial) measure of household or business e-commerce adoption exists; the only regional split of household internet access is the 2012 ISQ survey, which we do not use.
25 +
26 +\subsection{Representativeness}\label{sec:data_repr}
27 +Fabri-Ka's store counts track the regional business base closely in levels --- Spearman correlations with ISQ business locations of 0.91 (all), 0.89 (retail) and 0.93 (manufacturing), and 0.84 with population --- but coverage per thousand business locations varies six-fold across regions (1.8 to 11.1, coefficient of variation 0.43; Table~\ref{tab:external}), with the agri-food and craft regions covered by dedicated directories (Saguenay--Lac-Saint-Jean, Bas-Saint-Laurent, Gaspésie) over-represented relative to Montréal and Laval. This is a discovery artefact, not a feature of the regional economies, and it is the main threat to every regional comparison below; Section~\ref{sec:robust} re-estimates the results on stores discovered through certification and association sources only and without Montréal.
28 +
29 +\input{tables/external_coverage}
added paper/sections/discussion.tex +2 −0
@@ -0,0 +1,2 @@
1 +\section{discussion}\label{sec:discussion}
2 +TODO.
added paper/sections/hypotheses.tex +17 −0
@@ -0,0 +1,17 @@
1 +\section{Conceptual framework and preregistered hypotheses}\label{sec:hyp}
2 +
3 +\paragraph{What a catalogue can and cannot reveal.} Let $s$ index merchants, $r(s)$ the administrative region of the merchant's business location, and $c$ one of twenty product categories. A listing is a product a merchant chooses to publish online at the snapshot date. A region's online assortment is therefore the union of the publication decisions of the merchants located there, filtered by the aggregator's discovery process. Three forces shape it: the regional production and retail base (which merchants exist), the decision to sell online and to publish a catalogue (adoption), and the aggregator's coverage (discovery). Only the first is of economic interest; the other two are nuisance and are the object of the representativeness audit. Because the unit is a listing and not a sale, an employee or a shipment, all quantities below are \emph{assortment} quantities, and because merchant location is not product origin, all regional statements concern merchants based in a region, not goods made there.
4 +
5 +\paragraph{Hypotheses.} The hypotheses, estimands, specifications, exclusions and decision rules were frozen in a preregistration document before any Phase~4 statistic was computed (SHA-256 recorded in the decision log; two amendments, A-001 on the region-assignment rule and A-002 on category labels, are reported in Section~\ref{sec:data} and were decided before seeing results under the amended definitions).
6 +
7 +\begin{description}[leftmargin=1.2em,style=nextline]
8 +\item[H1 --- Regional specialization (confirmatory).] Assortment composition differs across regions beyond what a random allocation of merchants to regions would produce. Statistic: Cramér's $V$ of the region$\times$category table; null: 2{,}000 permutations of region labels across stores. Decision: $p<0.05$ (Holm-adjusted) under both merchant-equal and trimmed-listing weights.
9 +\item[H2 --- Diversity and scale (confirmatory, low power).] Rarefied assortment diversity (entropy at fifteen stores) is associated with regional population. Statistic: Spearman $\rho$ with a bootstrap interval; supported if the interval excludes zero.
10 +\item[H3 --- Concentration (confirmatory).] Catalogue sizes are highly skewed: Gini $\ge0.70$ and top-decile share $\ge0.50$, with bootstrap intervals.
11 +\item[H4 --- Related diversification (exploratory).] Within-store entry into categories absent at baseline increases with relatedness density; region-level entries are counted and declared untestable if fewer than ten.
12 +\item[H5 --- Price dispersion (confirmatory, two tests).] For matched products, posted prices are (T1) geographically structured --- joint wild-cluster-bootstrap test of region effects --- and (T2) competitively structured --- reseller versus producer effect.
13 +\item[H6 --- Local-origin premium.] Declared \emph{untestable} ex ante: the source has no product-level provenance. A store-certification contrast is reported descriptively and labelled as not being H6.
14 +\item[H7 --- Digital-assortment complexity (validation).] A region--category complexity indicator is reported only if it passes bootstrap rank stability ($\rho\ge0.8$), a store-reshuffle null, and a leave-one-region-out predictive check; otherwise it is reported as unstable and excluded from the index family.
15 +\end{description}
16 +
17 +The confirmatory family is $\{$H1, H2, H3, H5-T1, H5-T2$\}$ with Holm step-down at $\alpha=0.05$; raw $p$-values are also reported. Every causal reading is excluded by design: the data are a single cross-section of posted listings from a non-probability discovery process.
added paper/sections/institutional.tex +7 −0
@@ -0,0 +1,7 @@
1 +\section{Institutional setting and the Fabri-Ka platform}\label{sec:setting}
2 +
3 +\paragraph{Quebec's administrative regions.} Quebec is divided into seventeen administrative regions (\emph{régions administratives}) defined by provincial decree and used by the Institut de la statistique du Québec (ISQ) for regional accounts, population estimates and business counts \citep{isq2024,quebec_regions}. Statistics Canada's economic regions for Quebec are by construction groupings of complete census divisions that coincide with the administrative regions \citep{statcan_br}; two census divisions (Brome-Missisquoi and La Haute-Yamaska) were transferred from Montérégie to Estrie on 28 July 2021, so Statistics Canada series on 2021 economic-region boundaries predate that change for those two regions while ISQ series and the current boundary files of the Ministère des Ressources naturelles et des Forêts (MRNF) reflect it. We use the MRNF boundaries (version 2026-10) and ISQ regional series throughout, and Statistics Canada series only where ISQ has no equivalent (Section~\ref{sec:data_external}).
4 +
5 +\paragraph{Fabri-Ka.} Fabri-Ka (\url{https://www.fabri-ka.com}) is a product-discovery aggregator built and operated by the author. It does not sell: each listing links to the merchant's own storefront. Its construction has two stages that matter for interpretation. First, a \emph{discovery} pipeline assembles a registry of Quebec online stores from certification registries (Aliments du Québec, Les Produits du Québec, the CARTV organic register), sector associations (maple, cheese, cider, wine, distillery and microbrewery associations, the Conseil des métiers d'art), regional agri-food brands (Zone boréale, Créateurs de saveurs, Croquez Outaouais, Gaspésie gourmande and others), marketplaces and gift directories, and category-by-region search sweeps; each domain is visited, its e-commerce platform detected, its public catalogue endpoint tested, and Quebec location signals (postal codes, telephone area codes, wording) recorded. Second, \emph{connectors} by e-commerce platform --- Shopify's \texttt{products.json}, the WooCommerce Store API, Wix, Squarespace, Square, Votresite and a generic JSON-LD reader --- synchronise each store's public catalogue about every six hours, upserting listings by a content hash and retiring listings after two consecutive misses. Compliance per source (robots directives, terms, public endpoints only, removal on request) is documented in the platform's own compliance file and summarised in Appendix~\ref{app:source}.
6 +
7 +Three features of the platform shape what the data can and cannot say. (i) The unit is a merchant's \emph{published listing} at a point in time; the database keeps only the current price per listing, so there is no price history. (ii) Each store carries an \emph{origin class} assigned from the discovery source (a maple producer listed by the producers' association is class A with confidence 0.95; a retailer listed by Les Produits du Québec is class C) and adjusted by site wording, with the textual evidence retained; this is a store-level prior, not product-level provenance. Les Produits du Québec's marks --- ``Produit du Québec'' (at least 85\% of direct production and manufacturing costs incurred in Quebec plus final substantial transformation), ``Fabriqué au Québec'' and ``Conçu au Québec'' --- and the Aliments du Québec marks \citep{produitsqc,quebec_aliments,alimentsqc} are the only admissible origin evidence, and they attach to enterprises, not to listings. (iii) Discovery favours certified, association-member and search-indexed businesses and deliberately topped up Montréal through dedicated sources; it under-represents artisans without a web shop, industrial manufacturers selling direct, Etsy sellers (excluded by Etsy's terms) and Nord-du-Québec. The platform's public counters on 1 November 2026 reported 390{,}108 products, 1{,}569 live stores and 17 regions; the research sample derived below is smaller, and Section~\ref{sec:data} traces every exclusion.
modified paper/sections/introduction.tex +2 −14
@@ -1,14 +1,2 @@
1 −% ============================================================================
2 −\section{Introduction}
3 −\label{sec:introduction}
4 −% ============================================================================
5 −
6 −The question of \ldots{} has attracted considerable attention in the finance and economics literature. Despite the growing body of evidence, the mechanisms underlying \ldots{} remain poorly understood. This paper contributes to this line of research by \ldots{}
7 −
8 −The motivation for this study is threefold. First, \ldots{} Second, \ldots{} Third, \ldots{}
9 −
10 −Our main findings can be summarized as follows. We show that \ldots{} Furthermore, we document that \ldots{} Finally, our robustness checks confirm that \ldots{}
11 −
12 −This paper relates to several strands of the literature. First, it contributes to the literature on \ldots{} \citep{fama1993common}. Second, it builds on the work of \citet{bollerslev1986generalized} by \ldots{} Third, our methodological approach draws on \citet{newey1987simple}.
13 −
14 −The remainder of the paper is organized as follows. Section~\ref{sec:literature} reviews the related literature. Section~\ref{sec:data} describes the data. Section~\ref{sec:methodology} presents the empirical methodology. Section~\ref{sec:results} reports the main results. Section~\ref{sec:robustness} provides robustness checks. Section~\ref{sec:conclusion} concludes.
1 +\section{introduction}\label{sec:introduction}
2 +TODO.
modified paper/sections/literature.tex +10 −14
@@ -1,21 +1,17 @@
1 −% ============================================================================
2 −\section{Literature Review}
3 −\label{sec:literature}
4 −% ============================================================================
1 +\section{Related literature and positioning}\label{sec:lit}
5 2
6 −This section reviews the main theoretical and empirical contributions related to our research question.
3 +The paper draws on six literatures. We summarise what each establishes, and where the present exercise sits relative to its closest empirical precedents. Every reference below was verified against Crossref or the publisher's page before citation; one critique of the method of reflections that could not be verified is not cited.
7 4
8 −\subsection{Theoretical Framework}
9 −\label{subsec:theory}
5 +\paragraph{Economic geography and regional specialization.} Industries are localised far beyond a random allocation of plants across space \citep{ellison1997,duranton2005,mori2005}, with heterogeneity across industries and scales \citep{rosenthal2001} that also holds in Canadian plant microdata \citep{baldwin2008}. The micro-foundations are sharing, matching and learning \citep{marshall1920,duranton2004}; density elasticities of productivity are modest and demanding to identify \citep{rosenthal2004,combes2015,duranton2020}. Whether local specialisation or diversity favours growth remains contested \citep{glaeser1992,henderson1995}, and the related/unrelated-variety decomposition of entropy is the modern operationalisation \citep{frenken2007,content2016}. Market size and love of variety \citep{krugman1980,krugman1991,fujita1999} and the skill-ordered comparative advantage of cities \citep{davis2020} motivate a link between regional scale and assortment breadth; urban scaling shows that the number of distinct business types grows roughly with the logarithm of population and that activities appear in a nested order across city sizes \citep{bettencourt2007,youn2016,hong2020}. For Quebec and Canada, \citet{polese2004,polese2006} and \citet{shearmur2007,shearmur2008} document a persistent centre--periphery gradient in employment location. None of this work uses the catalogues of online merchants as the object of measurement, and none covers Quebec's seventeen administrative regions in the 2020s. Our revealed assortment advantage (RAA) is the Balassa logic \citep{balassa1965} applied to listing counts, with the dartboard idea of \citet{ellison1997} supplying the null: a random allocation of \emph{merchants}, not listings, to regions.
10 6
11 −The theoretical foundations of \ldots{} were established by \citet{cochrane2005asset}. In their framework, \ldots{}
7 +\paragraph{Economic complexity, relatedness and diversification.} The Balassa index is skewed and unstable at the top, and symmetric or normalised variants exist \citep{hinloopen2001,yu2009,laursen2015}. The product space \citep{hidalgo2007} and the principle of relatedness \citep{hidalgo2018,boschma2017} are robust regularities: regions diversify into activities related to what they already do, documented with industries \citep{neffke2011}, patents \citep{kogler2013,rigby2015,balland2017}, occupations \citep{muneepeerakul2013} and firms \citep{gao2018}, and combined with complexity in the smart-specialisation framework \citep{balland2019}. Economic complexity and fitness indices summarise bipartite region--activity matrices \citep{hidalgo2009,hausmann2011,hausmann2014,tacchella2012,cristelli2013,servedio2018}; the ECI is formally a spectral clustering of a region-similarity matrix \citep{mealy2019}, rankings are sensitive to algorithm and sample \citep{morrison2017,sciarra2020}, and structure in bipartite matrices must be tested against degree-preserving null models \citep{saracco2015}; see \citet{hidalgo2021} and \citet{balland2022} for surveys. All applications we could verify use production-side bases (exports, patents, industries, occupations, firms). Nothing is known about the behaviour of these tools on a $17\times20$ matrix built from online listings, which is why we treat the complexity indicator as an exercise in validation rather than as a ranking (Section~\ref{sec:results_h7}).
12 8
13 −\subsection{Empirical Evidence}
14 −\label{subsec:empirical_evidence}
9 +\paragraph{Online retail, the long tail and the geography of online demand.} Online variety created consumer surplus and shifted sales toward niche products \citep{brynjolfsson2003,brynjolfsson2011,zentner2013,waldfogel2017,anderson2006}, but hits remain dominant and the tail is thin in revenue \citep{elberse2008,tan2017}; search-cost theory predicts both long tails and superstars \citep{barisaac2012}. E-commerce reallocates retail share toward large firms and platform owners \citep{goldmanis2010,zhu2018,einav2016}. The geography of online demand is systematic: consumers buy more online where local supply is thin and when they are preference minorities \citep{forman2009,choi2011,brynjolfsson2009}, demand heterogeneity across markets limits the value of online variety for products with local tastes \citep{quan2018}, distance still matters online \citep{hortacsu2009,blum2006,lendle2016}, gains are larger for remote consumers \citep{dolfen2023,couture2021}, and e-commerce changes relative prices across space \citep{jo2019,lieber2012,goldfarb2019}. Platform design shapes dispersion \citep{dinerstein2018,ellison2009,baye2004,baye2006,brynjolfsson2000,clay2001}. This stream is demand- or transaction-based. The supply-side question --- what independent local merchants put online and how it varies across the regions of one province --- is, to our knowledge, not addressed; \citet{quan2018} and \citet{forman2009} are the closest mirrors, and our data cannot say whether regional assortments reflect regional tastes.
15 10
16 −A growing body of empirical literature examines \ldots{} \citet{fama1993common} document that \ldots{} More recently, \citet{petersen2009estimating} show that \ldots{}
11 +\paragraph{Regional inequality and digital adoption.} Firms in large cities adopted the commercial internet first \citep{forman2005}; the wage gains from internet investment accrued to already-advantaged places \citep{forman2012,sinai2004,forman2018}; broadband effects on rural growth are positive but modest and hard to identify \citep{kolko2012,whitacre2014,haller2015,haller2019}; adoption and usage divides differ \citep{goldfarb2008}; Canadian household divides are documented with the Canadian Internet Use Survey \citep{haight2014,statcan_cius,statcan2023cius} and business digital adoption with the SDTIU \citep{statcan_sdtiu}; the OECD measurement framework is \citet{oecd2019} and the Quebec programme is \citet{atn_netendances}. We found no peer-reviewed study of regional e-commerce \emph{supply} in Canada or Quebec; the Quebec-specific evidence is grey literature \citep{isq2024}, and the paper is explicit that this stream is the thinnest in verified academic sources.
17 12
18 −\subsection{Contribution to the Literature}
19 −\label{subsec:contribution}
13 +\paragraph{Product matching, hedonic prices and spatial price dispersion.} Record linkage rests on \citet{fellegi1969} and \citet{christen2012}. Scraped online prices are a valid but selected source: online and offline prices coincide for most multi-channel retailers \citep{cavallo2017}, scraped data have specific frequency and stockout properties \citep{cavallo2018,cavallo2023}, and the Billion Prices Project set the standards \citep{cavallo2016}; online price-setting and cross-border integration are documented by \citet{gorodnichenko2017,gorodnichenko2018,cavallo2014}; web-scraped prices now enter official indices \citep{konny2022}. Dispersion is mostly relative and persistent \citep{kaplan2015,kaplan2019}; US chains price almost uniformly across locations \citep{dellavigna2019}; spatial price gaps grow with distance \citep{atkin2015}; variety and prices differ across cities \citep{handbury2015,handbury2021}; hedonic theory and practice follow \citet{rosen1974,pakes2003,bajari2005}. Cross-merchant dispersion among many \emph{independent} small online stores in one province, with product fixed effects from matched titles and SKUs, has no direct precedent; the near-collinearity of merchant and region effects is specific to our setting.
20 14
21 −Our paper departs from the existing literature in several important ways. First, \ldots{} Second, \ldots{} Third, \ldots{}
15 +\paragraph{Measurement error and representativeness of non-probability data.} For non-probability big data the error is governed by the data-defect correlation rather than by sample size \citep{meng2018,bradley2021,lazer2014}; inference requires explicit selection models \citep{elliott2017,buelens2018,heckman1979,groves2006,little2019}, and statistical agencies are explicit about the conditions \citep{daas2015,beaumont2020,rao2021}; measurement error in regressors and outcomes \citep{hausman2001,bound2001} and the modifiable areal unit problem \citep{briant2010} are additional threats. We take these literatures literally: Section~\ref{sec:data} reports a data-quality ledger, a category-label audit, a matching validation and a representativeness audit against Statistics Canada and ISQ counts.
16 +
17 +\paragraph{What is and is not new here.} The contribution is a documented, validated supply-side dataset of online product listings of Quebec-based merchants by administrative region; RAA and diversity measures with merchant-count-preserving permutation nulls and merchant-weighted variants; a region--category bipartite indicator whose eigenvector interpretation is tested against bootstraps and null models and reported as a family of components rather than a headline score; cross-merchant dispersion of matched products among independent stores with explicit collinearity diagnostics; and a representativeness audit of an aggregator against business counts. No method is new. We do not provide evidence on agglomeration economies, on diversification over time, on sales concentration, on gravity in online trade or on welfare, and we never call the indicator a production or economic complexity index: without verified product origin it describes observed online catalogues only.
deleted paper/sections/methodology.tex +0 −44
@@ -1,44 +0,0 @@
1 −% ============================================================================
2 −\section{Methodology}
3 −\label{sec:methodology}
4 −% ============================================================================
5 −
6 −\subsection{Empirical Model}
7 −\label{subsec:model}
8 −
9 −To test our hypotheses, we estimate the following baseline specification:
10 −%
11 −\begin{equation}
12 − y_{i,t} = \alpha + \beta_1 X_{i,t} + \beta_2 Z_{i,t} + \gamma_i + \delta_t + \varepsilon_{i,t},
13 − \label{eq:baseline}
14 −\end{equation}
15 −%
16 −where $y_{i,t}$ is the dependent variable for firm~$i$ in period~$t$, $X_{i,t}$ is the main variable of interest, $Z_{i,t}$ is a vector of control variables, $\gamma_i$ represents firm fixed effects, $\delta_t$ captures time fixed effects, and $\varepsilon_{i,t}$ is the error term.
17 −
18 −Our coefficient of interest is $\beta_1$. Under the null hypothesis that \ldots{}, we expect $\beta_1 = 0$. A positive (negative) estimate of $\beta_1$ would indicate \ldots{}
19 −
20 −\subsection{Identification Strategy}
21 −\label{subsec:identification}
22 −
23 −The main identification challenge arises from \ldots{} To address potential endogeneity concerns, we employ \ldots{}
24 −
25 −We cluster standard errors at the firm level to account for within-firm serial correlation, following \citet{petersen2009estimating}.
26 −
27 −\subsection{Extended Specifications}
28 −\label{subsec:extended}
29 −
30 −We extend the baseline model in several directions. First, we allow for nonlinear effects:
31 −%
32 −\begin{equation}
33 − y_{i,t} = \alpha + \beta_1 X_{i,t} + \beta_2 X_{i,t}^2 + \boldsymbol{\beta}_3' \mathbf{Z}_{i,t} + \gamma_i + \delta_t + \varepsilon_{i,t}.
34 − \label{eq:nonlinear}
35 −\end{equation}
36 −
37 −Second, we examine heterogeneous effects by interacting $X_{i,t}$ with \ldots{}:
38 −%
39 −\begin{equation}
40 − y_{i,t} = \alpha + \beta_1 X_{i,t} + \beta_2 D_{i,t} + \beta_3 (X_{i,t} \times D_{i,t}) + \boldsymbol{\beta}_4' \mathbf{Z}_{i,t} + \gamma_i + \delta_t + \varepsilon_{i,t},
41 − \label{eq:interaction}
42 −\end{equation}
43 −%
44 −where $D_{i,t}$ is an indicator variable equal to one if \ldots{} The coefficient $\beta_3$ captures the differential effect of $X$ for \ldots{}
added paper/sections/methods.tex +31 −0
@@ -0,0 +1,31 @@
1 +\section{Empirical methods and identification limits}\label{sec:methods}
2 +
3 +\subsection{Weighting schemes}
4 +Catalogue sizes are extremely skewed (Section~\ref{sec:results_h3}): the ten largest stores hold more than a third of all listings. Every regional statistic is therefore computed under three schemes. Under \textbf{L} each listing counts one. Under \textbf{M} (merchant-equal, the primary scheme for regional composition) each listing counts $1/n_s$, so that every store contributes one unit regardless of catalogue size. Under \textbf{T} listings count one after dropping the one per cent largest stores. The schemes answer different questions --- L describes what a visitor sees, M describes the population of merchants --- and we report where they disagree.
5 +
6 +\subsection{Revealed assortment advantage and the merchant-reshuffle null}
7 +For region $r$ and category $c$ let $N_{rc}$ be the (weighted) count of listings. The revealed assortment advantage is
8 +\begin{equation}
9 +\mathrm{RAA}_{rc}=\frac{N_{rc}/\sum_{c}N_{rc}}{\sum_{r}N_{rc}/\sum_{rc}N_{rc}},
10 +\end{equation}
11 +the Balassa index on listings. Unlike export RCA it does not measure production, trade or competitiveness; it measures what the merchants of a region publish relative to what Quebec merchants publish overall. Dependence between region and category is summarised by Cramér's $V$ of the $R\times C$ table and tested against a null that reshuffles region labels across \emph{stores} while keeping every store's catalogue and every region's number of stores fixed; the $p$-value is the share of 2{,}000 permutations with $V$ at least as large as observed. This is the dartboard logic of \citet{ellison1997} transposed to merchants: a region may look specialised simply because one large merchant is located there, and the null absorbs this. Uncertainty for each $\mathrm{RAA}_{rc}$ comes from 2{,}000 bootstrap resamples of stores; because resampling with replacement duplicates stores and biases $V$ upward, bootstrap intervals are reported for the cells and not for $V$, whose inference is by permutation.
12 +
13 +\subsection{Diversity and concentration}
14 +Regional diversity is the normalised Shannon entropy of category shares, $\tilde H_r=-\sum_c p_{rc}\ln p_{rc}/\ln 20$, the effective number of categories $\exp(H_r)$ and the Herfindahl index, under L and M. Because entropy increases mechanically with the number of merchants, the primary H2 outcome is the \emph{rarefied} entropy: the mean over 2{,}000 draws of fifteen stores without replacement, which puts regions with 15 and 235 stores on the same footing; regions with fewer than fifteen stores are excluded from that statistic. Concentration is the Gini coefficient of listings across stores, top-percentile shares, the rank--size slope, and a discrete power-law fit with $x_{\min}$ chosen by KS minimisation compared with a log-normal on the same tail by a Vuong likelihood-ratio test \citep[following][]{ellison1997}.
15 +
16 +\subsection{Region--category network and complexity indicator}
17 +Let $M_{rc}=\mathbf 1[\mathrm{RAA}_{rc}\ge1]$ under scheme M. Diversity is the row sum, ubiquity the column sum; the ECI is the second eigenvector of $\tilde M=D_r^{-1}MD_c^{-1}M^{\top}$ \citep{hidalgo2009,mealy2019}, with the sign fixed so that it correlates positively with diversity; the fitness index is the fixed point of the non-linear iteration of \citet{tacchella2012}. Category relatedness is the Hidalgo-type proximity $\phi_{cc'}=\min\{P(c\mid c'),P(c'\mid c)\}$ computed from co-occurrence of categories across \emph{stores}, and communities are obtained by Louvain over 200 seeds. Validation follows the preregistration: (V1) the Spearman correlation between the full-sample ECI and 500 store-bootstrap ECIs; (V2) the second eigenvalue of $\tilde M$ and the spectral radius of the bipartite adjacency matrix against 2{,}000 store-level region reshuffles \citep[a merchant-preserving analogue of the null models in][]{saracco2015}; (V3) the leave-one-region-out predictive correlation of the ECI with regional population, business locations and income; (V4) the normalised mutual information between community partitions across seeds.
18 +
19 +\subsection{Matched products and hedonic dispersion}
20 +No GTIN exists in the source. Cross-merchant identity is inferred in tiers --- A: shared merchant SKU and token-set title similarity $\ge70$; B: same normalised brand and identical normalised title; C: same brand, same category and token-sort similarity $\ge90$; D: identical title without brand --- restricted to listings in the price sample and to pairs of different stores that are not mirror domains of one business. Tiers A and B form the primary sample and C a sensitivity; D is never used for inference. Precision is estimated on a labelled sample of 240 pairs (Section~\ref{sec:data}). For listing $i$ in match group $g$ sold by store $m$ in region $r(m)$,
21 +\begin{equation}
22 +\ln P_{i}=\alpha_{g}+\gamma_{r(m)}+\delta_{\mathrm{platform}(m)}+\beta_1\ln n_m+\beta_2\,\mathrm{Reseller}_m+\beta_3\,\mathrm{Certified}_m+\varepsilon_{i},
23 +\label{eq:hedonic}
24 +\end{equation}
25 +estimated by within-group least squares with standard errors clustered by store. Because each store belongs to one region, a store fixed effect cannot be included alongside $\gamma_r$: region effects are identified only through match groups that span regions, which is why the sample size in \emph{stores} and the number of regions with at least five matched stores are reported, and why inference uses a wild cluster bootstrap (Rademacher weights by store, 9{,}999 draws, restricted residuals) for the joint test of $\gamma_r$ and for $\beta_2$. Prices are posted minimum-variant prices at the snapshot; shipping, taxes and transactions are not observed.
26 +
27 +\subsection{Dynamics}
28 +The only temporal margin is listing entry and exit after store onboarding ended (24 August 2026). Weekly presence of each (store, category) pair is reconstructed from first and last observation dates; relatedness density is computed from the baseline weeks 36--39 and entry is measured over weeks 40--44, a temporal holdout that prevents the outcome from feeding the regressor. A linear probability model with store and category fixed effects and store-clustered standard errors is compared with a placebo that relabels categories in the proximity matrix. The exercise is exploratory by preregistration.
29 +
30 +\subsection{External data and the representativeness audit}
31 +Regional covariates come from the ISQ (population at 1 July 2025, business locations with employees in December 2025 by NAICS, disposable income per capita 2024, regional GDP 2024) and from Statistics Canada (Census 2021 land area and density on 2021 economic-region boundaries); vintages and concordance issues are recorded in the external-data manifest. Coverage is the number of Fabri-Ka stores per thousand business locations and per 100{,}000 inhabitants; the audit also tabulates discovery source and platform by region and category mix by platform, following the data-defect logic of \citet{meng2018}.
added paper/sections/reproducibility.tex +2 −0
@@ -0,0 +1,2 @@
1 +\section*{Reproducibility statement}\addcontentsline{toc}{section}{Reproducibility statement}
2 +The analysis is a deterministic pipeline (seed 20261101) run from a single \texttt{Makefile}: \texttt{make ingest clean match validate features models robust tables figures paper}, with \texttt{make test} running the invariant tests on the cleaned panel. The raw snapshot is an owner-authorised, read-only copy of the Fabri-Ka database taken with \texttt{sqlite3.Connection.backup()} on 2026-11-01T17:38:30Z (SHA-256 \texttt{8d963ba9\ldots98fb}); the two tables containing personal data were dropped on the production node before transfer and nothing in production was modified. The snapshot is not redistributed (it contains merchant catalogues that belong to the merchants); the data dictionary, the sample-flow table, every intermediate aggregate, the labelled audit samples, the external-data manifest with URLs, licences, vintages and checksums, the run ledger with every API call and its cost, and the SHA-256 of every output are in the repository. LLM annotations (two independent models for the category audit and the matching validation; one model for the classifier training labels) are stored as CSV files so that the pipeline runs without API access; no empirical result depends on a live model call. Dependencies are pinned in \texttt{pyproject.toml} (Python 3.12).
modified paper/sections/results.tex +2 −27
@@ -1,27 +1,2 @@
1 −% ============================================================================
2 −\section{Results}
3 −\label{sec:results}
4 −% ============================================================================
5 −
6 −\subsection{Baseline Results}
7 −\label{subsec:baseline_results}
8 −
9 −Table~\ref{tab:regression} presents the estimation results of our baseline model specified in Equation~\eqref{eq:baseline}. Column~(1) reports the OLS estimates without fixed effects. Columns~(2)--(4) progressively add firm and time fixed effects and the full set of controls.
10 −
11 −\input{tables/regression}
12 −
13 −The coefficient on $X_{i,t}$ is positive and statistically significant at the 1\% level across all specifications. The point estimate in our preferred specification (Column~4) implies that a one-standard-deviation increase in $X$ is associated with a \ldots{} increase in $y$.
14 −
15 −\subsection{Graphical Evidence}
16 −\label{subsec:graphical}
17 −
18 −Figure~\ref{fig:main_result} provides graphical evidence supporting our main findings.
19 −
20 −\input{figures/main_figure}
21 −
22 −The figure illustrates \ldots{} The upward trend in \ldots{} is consistent with the regression estimates reported in Table~\ref{tab:regression}.
23 −
24 −\subsection{Sub-sample Analysis}
25 −\label{subsec:subsample}
26 −
27 −To explore potential heterogeneity, we re-estimate Equation~\eqref{eq:baseline} across sub-samples defined by \ldots{} The results, available in Table~\ref{tab:app_subsample} of Appendix~\ref{app:tables}, indicate that \ldots{}
1 +\section{results}\label{sec:results}
2 +TODO.
modified paper/sections/robustness.tex +2 −26
@@ -1,26 +1,2 @@
1 −% ============================================================================
2 −\section{Robustness Checks}
3 −\label{sec:robustness}
4 −% ============================================================================
5 −
6 −We conduct several robustness tests to assess the sensitivity of our baseline results.
7 −
8 −\subsection{Alternative Variable Definitions}
9 −\label{subsec:alt_definitions}
10 −
11 −We first verify that our results are not driven by the specific construction of the dependent variable. Table~\ref{tab:app_robustness} in Appendix~\ref{app:tables} reports estimates using \ldots{} as alternative measures. The coefficient on $X_{i,t}$ remains statistically significant and economically meaningful across all specifications.
12 −
13 −\subsection{Alternative Estimation Methods}
14 −\label{subsec:alt_estimation}
15 −
16 −We re-estimate the model using \ldots{} to address potential concerns about \ldots{} The results are qualitatively unchanged.
17 −
18 −\subsection{Placebo Tests}
19 −\label{subsec:placebo}
20 −
21 −As a falsification exercise, we replace the main explanatory variable with \ldots{} If our baseline results reflect a genuine economic relationship rather than spurious correlation, we expect the placebo coefficients to be statistically insignificant. Consistent with this expectation, none of the placebo estimates are significant at conventional levels.
22 −
23 −\subsection{Sensitivity to Sample Period}
24 −\label{subsec:sample_period}
25 −
26 −We examine whether our results are sensitive to the sample period by \ldots{} The estimates remain stable across sub-periods, suggesting that our findings are not driven by a specific time window.
1 +\section{robustness}\label{sec:robustness}
2 +TODO.
modified paper/tables/category_audit.tex +1 −1
@@ -24,5 +24,5 @@ Industrial & 24 & 0.42 \\
24 24 Other & 25 & 0.00 \\
25 25 \textbf{All} & 474 & \textbf{0.46} \\
26 26 \bottomrule\end{tabular}
27 −\begin{minipage}{0.95\textwidth}\vspace{2pt}\footnotesize\textit{Notes:} 25 random listings per category annotated independently by two LLMs (claude-opus-5-5, gpt-5.5) with the platform's taxonomy; agreement rule vs A 0.45, rule vs B 0.45, A vs B 0.95; consensus exists for 95% of listings. Precision = share of consensus labels equal to the rule label. Disagreements listed in the replication package.\end{minipage}
27 +\begin{minipage}{0.95\textwidth}\vspace{2pt}\footnotesize\textit{Notes:} 25 random listings per category annotated independently by two LLMs (claude-opus-5-5, gpt-5.5) with the platform's taxonomy; agreement rule vs A 0.45, rule vs B 0.45, A vs B 0.95; consensus exists for 95\% of listings. Precision = share of consensus labels equal to the rule label. Disagreements listed in the replication package.\end{minipage}
28 28 \end{table}
modified paper/tables/match_validation.tex +1 −1
@@ -7,5 +7,5 @@ B & 54 & 0.90 & 0.00 & 0.96 \\
7 7 C & 52 & 0.77 & 0.10 & 0.77 \\
8 8 D & 48 & 0.17 & 0.60 & 0.19 \\
9 9 \bottomrule\end{tabular}
10 −\begin{minipage}{0.95\textwidth}\vspace{2pt}\footnotesize\textit{Notes:} Two independent LLM annotators (agreement 0.88; consensus on 88% of pairs). Tier A = shared merchant SKU; B = same brand and identical normalised title; C = same brand, fuzzy title; D = identical title without brand (never used for inference).\end{minipage}
10 +\begin{minipage}{0.95\textwidth}\vspace{2pt}\footnotesize\textit{Notes:} Two independent LLM annotators (agreement 0.88; consensus on 88\% of pairs). Tier A = shared merchant SKU; B = same brand and identical normalised title; C = same brand, fuzzy title; D = identical title without brand (never used for inference).\end{minipage}
11 11 \end{table}
added reports/hypothesis_registry_rule.md +24 −0
@@ -0,0 +1,24 @@
1 +# Hypothesis registry (rule category labelling)
2 +
3 +| id | statement | primary statistic | p raw | p Holm | status |
4 +|---|---|---|---|---|---|
5 +| H1 | Online assortment composition differs across regions beyond what merchant counts imply | Cramér's V (M) = 0.105, store-reshuffle p = 0.0005; (T) V = 0.159, p = 0.492; (L) p = 0.058 | 0.4923 | 0.9434 | **partially supported** |
6 +| H2 | Regional diversity is associated with regional scale | Spearman rho(rarefied entropy, log population) = -0.20, 95% CI [-0.62; 0.34], n = 16 | 0.4717 | 0.9434 | **inconclusive** |
7 +| H3 | Merchant catalogue sizes are highly skewed | Gini = 0.849 [0.811; 0.878]; top-10% share = 0.789 [0.730; 0.836] | | | **supported** |
8 +| H4 | Related diversification (exploratory) | LPM density coefficient 0.111 (SE 0.050), placebo p = 0.010; 149 entries; 11 region-level entries | | | **exploratory: consistent with relatedness at the store level; region level untestable** |
9 +| H5-T1 | Matched-product prices are geographically structured | Wald(14) = 164.4, wild-cluster-bootstrap p = 0.0045; 1064 groups, 143 stores, 9 regions with >= 5 stores | 0.0045 | 0.0135 | **supported** |
10 +| H5-T2 | Matched-product prices are competitively structured (reseller vs producer) | reseller coefficient -0.116 (SE 0.026), wild-bootstrap p = 0.0012 | 0.0012 | 0.0048 | **supported** |
11 +| H6 | Local-origin price premium | not estimated | | | **untestable (declared ex ante)** |
12 +| H7 | Validated digital-assortment complexity indicator | ECI bootstrap rank stability median rho = 0.20 (criterion 0.8); lambda2 null p = 0.41; LOO predictive rho = 0.39 | | | **not validated: ECI unstable at 17 x 20; omitted from QDACI** |
13 +| QDACI | Composite index | median agreement across weightings rho = 0.68 (rule 0.7); bootstrap rank stability 0.50 | | | **components published; composite withheld** |
14 +
15 +Notes:
16 +- H1: Preregistered criterion required p < 0.05 under both merchant-equal and trimmed-listing weights; met for merchant-equal only. Specialization is a property of the merchant population, not of listing volumes.
17 +- H2: CI includes zero and economically meaningful values in both directions; n = 16 regions. No association with business counts or density either (see table).
18 +- H3: Both preregistered thresholds (Gini >= 0.70, top-10% >= 0.50) exceeded with CIs entirely above them; power-law tail favoured over log-normal on the tail.
19 +- H4: Fewer than 10 region-level entries (preregistered untestability rule); store-level association over 5 weeks, no causal claim.
20 +- H5-T1: Region effects identified only through groups spanning regions; posted prices, no shipping/taxes.
21 +- H5-T2: Resellers/collectives post lower prices than producers for the same product.
22 +- H6: No product-level provenance; a store-certification contrast is reported descriptively and is not H6.
23 +- H7: Fitness and plain diversity are more stable but still below the criterion; the region-category matrix is not more nested than the store-reshuffle null.
24 +- QDACI: Components describe observed online catalogues of Quebec-based merchants, not production or output. Complexity omitted because the ECI failed the preregistered stability criterion.
\ No newline at end of file
modified research/decisions.md +1 −0
@@ -10,3 +10,4 @@
10 10 | D-006 | 2026-11-01 | Seed 20261101 for every bootstrap/permutation; outputs hashed in `logs/manifest.json` | reproducibility |
11 11 | D-007 | 2026-11-01 | Preregistration frozen: `research/preregistration.md` sha256 `b7cd3f177510af0b29d5a67c84f14562b068c36c789de1f5ea078ef4697e180b` | mandate Phase 3; frozen before any Phase 4 computation |
12 12 | A-001 | 2026-11-01 | **Amendment** to preregistration §2 / D-003: region assignment = municipality lookup of the FSA sector label (urban FSAs only, official MERN municipality table, current boundaries) > Fabri-Ka label (directory/site/FSA map) > point-in-polygon of the FSA centroid in the MERN administrative-region layer (V2026-10) > unknown. A point-in-polygon-first variant is the sensitivity sample. | After freezing we found (i) a first attempt with the StatCan 2021 economic-region layer produced spurious Estrie/Montérégie conflicts because it predates the 2021 transfer of Brome-Missisquoi and La Haute-Yamaska to Estrie — the official MERN layer and municipality table are used instead; (ii) 99 apparent conflicts were a double-hyphen spelling artefact; (iii) remaining disagreements (139) are 69 % rural FSAs whose centroids fall near borders, so the directory label is preferred when present. Region recovery adds 116 stores that had no label. |
13 +| A-002 | 2026-11-01 | **Amendment** to preregistration §2 (categories "used as labelled"): the 500-listing audit found the platform's keyword rules agree with annotator consensus on only 46 % of listings (two LLM annotators agree with each other on 95 %). A supervised classifier (OpenAI text-embedding-3-small + one-hot rule label, multinomial logistic regression) is trained on 6 481 listings labelled by claude-sonnet-5-5 and validated on the audit consensus sample (held out). **All category-based results (H1, H2, H4, H7, QDACI, robustness) are reported under both labellings; the model-based labelling becomes the primary one only if its audit accuracy exceeds the rule labelling by at least 20 points; otherwise the preregistered rule labelling stays primary.** | measurement validity; decided before seeing any result under the model labelling |
modified src/clean/listings.py +2 −2
@@ -18,7 +18,7 @@ Flags (all kept, so every analysis can state its own exclusion set):
18 18 from __future__ import annotations
19 19 import json, re, unicodedata
20 20 import numpy as np, pandas as pd
21 −from src.common import ROOT, P, get_logger, ledger, register_output, write_json
21 +from src.common import ROOT, P, get_logger, ledger, register_output, write_json, BASE_PROC, BASE_EXP
22 22
23 23 log = get_logger("clean.listings")
24 24 PRICE_CAP = 50_000.0
@@ -93,7 +93,7 @@ def main():
93 93 "price_quantiles_cad": {str(q): round(float(base.loc[base.f_analysis_price, "price"].quantile(q)), 2) for q in (0.001, 0.01, 0.1, 0.5, 0.9, 0.99, 0.999)},
94 94 "top10_stores_share_of_assortment": round(float(a.store_id.value_counts().head(10).sum() / len(a)), 4),
95 95 }
96 − write_json(diag, ROOT / "experiments" / "sample_flow.json", "clean.listings")
96 + write_json(diag, BASE_EXP / "sample_flow.json", "clean.listings")
97 97 md = ["# Sample flow (attrition) — WP38", "", f"Snapshot 2026-11-01T17:38:30Z. Nothing is deleted; every step is a flag in `data/interim/listings_clean.parquet`.", "",
98 98 "| step | listings |", "|---|---:|"] + [f"| {k} | {v:,} |".replace(",", " ") for k, v in flow] + ["",
99 99 "## Assortment sample diagnostics", "```", json.dumps(diag["assortment_sample"], indent=1), "```",
modified src/clean/siblings.py +3 −3
@@ -10,7 +10,7 @@ the store with the most listings (ties: alphabetical). Output: data/interim/sibl
10 10 from __future__ import annotations
11 11 import itertools, json, re
12 12 import pandas as pd, networkx as nx
13 −from src.common import ROOT, P, get_logger, ledger, register_output, write_json
13 +from src.common import ROOT, P, get_logger, ledger, register_output, write_json, BASE_PROC, BASE_EXP
14 14
15 15 log = get_logger("clean.siblings")
16 16
@@ -59,12 +59,12 @@ def main():
59 59 for s in comp: rows.append((gid, s, comp[0], s == comp[0], sizes.get(s, 0)))
60 60 sib = pd.DataFrame(rows, columns=["sibling_group", "id", "sibling_rep", "is_rep", "n_titles"])
61 61 sib.to_parquet(P["interim"] / "sibling_stores.parquet", index=False); register_output(P["interim"] / "sibling_stores.parquet", "siblings")
62 − pd.DataFrame(reasons, columns=["a", "b", "reason", "overlap"]).to_csv(ROOT / "experiments" / "sibling_pairs.csv", index=False)
62 + pd.DataFrame(reasons, columns=["a", "b", "reason", "overlap"]).to_csv(BASE_EXP / "sibling_pairs.csv", index=False)
63 63 summ = {"sibling_groups": int(sib.sibling_group.nunique()) if len(sib) else 0, "stores_in_groups": len(sib), "non_rep_stores": int((~sib.is_rep).sum()) if len(sib) else 0,
64 64 "non_rep_listings": int(a[a.store_id.isin(sib[~sib.is_rep].id)].shape[0]) if len(sib) else 0,
65 65 "reasons": pd.Series([r[2] for r in reasons]).value_counts().to_dict(),
66 66 "largest_groups": sib.groupby("sibling_group").id.apply(list).map(lambda l: l[:4]).head(12).tolist()}
67 − write_json(summ, ROOT / "experiments" / "siblings.json", "siblings"); ledger("clean.siblings", **summ)
67 + write_json(summ, BASE_EXP / "siblings.json", "siblings"); ledger("clean.siblings", **summ)
68 68 log.info(json.dumps(summ, ensure_ascii=False))
69 69
70 70
modified src/clean/stores.py +2 −2
@@ -15,7 +15,7 @@ Region logic (documented in reports/phase2_data_audit.md):
15 15 from __future__ import annotations
16 16 import glob, json
17 17 import numpy as np, pandas as pd
18 −from src.common import ROOT, P, REGIONS, get_logger, ledger, register_output, write_json
18 +from src.common import ROOT, P, REGIONS, get_logger, ledger, register_output, write_json, BASE_PROC, BASE_EXP
19 19
20 20 log = get_logger("clean.stores")
21 21
@@ -136,7 +136,7 @@ def main():
136 136 "agreement_db_vs_pip": (round(float((out.region_db == out.region_pip)[out.region_db.notna() & out.region_pip.notna()].mean()), 4) if out.region_pip.notna().any() else None),
137 137 "agreement_city_vs_pip": (round(float((out.region_city == out.region_pip)[out.region_city.notna() & out.region_pip.notna()].mean()), 4) if out.region_city.notna().any() else None),
138 138 "agreement_db_vs_final": round(float((out.region_db == out.region_final)[out.region_db.notna()].mean()), 4)}
139 − write_json(summ, ROOT / "experiments" / "store_master.json", "clean.stores")
139 + write_json(summ, BASE_EXP / "store_master.json", "clean.stores")
140 140 ledger("clean.stores", **summ)
141 141 log.info(json.dumps(summ, ensure_ascii=False))
142 142
modified src/common.py +7 −0
@@ -7,9 +7,16 @@ ROOT = pathlib.Path(__file__).resolve().parents[1]
7 7 SETTINGS = yaml.safe_load((ROOT / "config" / "settings.yaml").read_text())
8 8 SEED = int(SETTINGS["seed"])
9 9 REGIONS = list(SETTINGS["regions"])
10 +LABEL = os.environ.get("WP38_LABEL", "rule") # "rule" (platform keyword labels) or "model" (A-002 classifier labels)
11 +SUFFIX = "" if LABEL == "rule" else f"_{LABEL}"
10 12 P = {k: ROOT / v for k, v in SETTINGS["paths"].items()}
13 +P["processed"] = ROOT / f"data/processed{SUFFIX}"; P["experiments"] = ROOT / f"experiments{SUFFIX}"
14 +P["figures"] = ROOT / f"paper/figures{SUFFIX}"; P["tables"] = ROOT / f"paper/tables{SUFFIX}"
11 15 for _p in P.values():
12 16 _p.mkdir(parents=True, exist_ok=True)
17 +PROC_DIR, EXP_DIR = P["processed"], P["experiments"]
18 +BASE_PROC, BASE_EXP = ROOT / "data/processed", ROOT / "experiments" # label-independent artefacts (cleaning, audits, matching)
19 +BASE_PROC.mkdir(parents=True, exist_ok=True); BASE_EXP.mkdir(parents=True, exist_ok=True)
13 20
14 21
15 22 def get_logger(name: str) -> logging.Logger:
added src/features/category_model.py +119 −0
@@ -0,0 +1,119 @@
1 +"""Model-based category labels (Amendment A-002): the platform's keyword rules reach only ~46 % precision against annotator
2 +consensus, so we train a supervised classifier on LLM-annotated listings and validate it on the independent 500-listing audit
3 +sample (never used for training).
4 +
5 +Steps (python -m src.features.category_model <step>):
6 + sample 6 000 listings stratified by rule category (proportional to size, floor 150; audit-sample uids excluded) -> data/processed/category_train_sample.csv
7 + annotate claude-sonnet-5-5 (adaptive thinking, low effort) labels each listing with one of the 20 keys; usage logged; never overwritten
8 + embed OpenAI text-embedding-3-small on "title | product_type | merchant_categories | vendor | tags" for ALL assortment listings
9 + -> data/interim/listing_embeddings.npy (+ uid index); cached
10 + train multinomial logistic regression on [embedding, one-hot rule label]; 5-fold CV; evaluation on the audit consensus sample
11 + -> data/processed/category_model.parquet (uid, category_model, p_max), experiments/category_model.json
12 +"""
13 +from __future__ import annotations
14 +import json, sys, time, concurrent.futures as cf
15 +import numpy as np, pandas as pd
16 +from dotenv import load_dotenv
17 +from src.common import ROOT, P, SEED, get_logger, ledger, register_output, write_json, BASE_PROC, BASE_EXP
18 +from src.validation.category_audit import TAXO, SYSTEM, fiche, parse, SAMPLE as AUDIT
19 +
20 +log = get_logger("features.category_model"); load_dotenv(ROOT / ".env")
21 +PROC = BASE_PROC; TRAIN = PROC / "category_train_sample.csv"; EMB = P["interim"] / "listing_embeddings.npy"; EMB_IDX = P["interim"] / "listing_embeddings_uids.csv"
22 +N_TRAIN, FLOOR = 6000, 150
23 +
24 +
25 +def sample():
26 + if TRAIN.exists(): log.error("%s exists — refusing to overwrite", TRAIN); return
27 + li = pd.read_parquet(P["interim"] / "listings_clean.parquet"); a = li[li.f_analysis_assort]
28 + audit = set(pd.read_csv(AUDIT).uid); a = a[~a.uid.isin(audit)]
29 + share = a.category.value_counts(normalize=True); n = (share * N_TRAIN).round().clip(lower=FLOOR).astype(int)
30 + s = pd.concat([g.sample(min(int(n[c]), len(g)), random_state=SEED) for c, g in a.groupby("category")])
31 + cols = ["uid", "store_id", "category", "title", "product_type", "merchant_categories", "tags_list", "vendor", "price", "currency", "description_head"]
32 + s[cols].to_csv(TRAIN, index=False); log.info("train sample %s", len(s))
33 +
34 +
35 +def _label(text):
36 + import anthropic
37 + m = anthropic.Anthropic().messages.create(model="claude-sonnet-5-5", max_tokens=600, thinking={"type": "adaptive"}, output_config={"effort": "low"}, system=SYSTEM, messages=[{"role": "user", "content": text}])
38 + return next((b.text for b in m.content if getattr(b, "type", "") == "text"), ""), {"in": m.usage.input_tokens, "out": m.usage.output_tokens}
39 +
40 +
41 +def annotate():
42 + df = pd.read_csv(TRAIN)
43 + if "llm" not in df.columns: df["llm"] = None
44 + df.loc[df.llm.isin(["PARSE_ERROR", "INVALID"]), "llm"] = None
45 + todo = df.index[df.llm.isna()].tolist(); usage = {"in": 0, "out": 0, "calls": 0, "errors": 0}; t0 = time.time()
46 + def work(i):
47 + r = df.loc[i]
48 + for k in range(6):
49 + try: return i, *_label(fiche(r))
50 + except Exception as e: time.sleep(min(60, 3 * 2 ** k)); err = str(e)[:100]
51 + return i, f"ERROR {err}", {"in": 0, "out": 0}
52 + with cf.ThreadPoolExecutor(8) as ex:
53 + for j, (i, out, u) in enumerate(ex.map(work, todo)):
54 + df.loc[i, "llm"] = parse(out)[0]; usage["in"] += u["in"]; usage["out"] += u["out"]; usage["calls"] += 1; usage["errors"] += out.startswith("ERROR")
55 + if j % 500 == 499: df.to_csv(TRAIN, index=False); log.info("%s/%s %.0fs", j + 1, len(todo), time.time() - t0)
56 + df.to_csv(TRAIN, index=False); ledger("category_model.annotate", model="claude-sonnet-5-5", **usage); log.info("annotate done %s", usage)
57 +
58 +
59 +def text_of(r):
60 + return " | ".join(str(x) for x in (r.title, r.product_type or "", r.merchant_categories or "", r.vendor or "", r.tags_list if r.tags_list != "[]" else "") if x)[:600]
61 +
62 +
63 +def embed():
64 + from openai import OpenAI
65 + li = pd.read_parquet(P["interim"] / "listings_clean.parquet"); a = li[li.f_analysis_assort | li.uid.isin(pd.read_csv(AUDIT).uid)]
66 + texts = [text_of(r) for r in a.itertuples()]; uids = a.uid.tolist()
67 + done = {}
68 + if EMB.exists():
69 + old = np.load(EMB); oid = pd.read_csv(EMB_IDX).uid.tolist(); done = dict(zip(oid, old))
70 + todo = [i for i, u in enumerate(uids) if u not in done]; log.info("embedding %s of %s listings", len(todo), len(uids))
71 + client = OpenAI(); usage = 0; t0 = time.time()
72 + def batch(idx):
73 + for k in range(5):
74 + try:
75 + r = client.embeddings.create(model="text-embedding-3-small", input=[texts[i] for i in idx], dimensions=512); return idx, [e.embedding for e in r.data], r.usage.total_tokens
76 + except Exception as e: time.sleep(min(60, 3 * 2 ** k)); err = str(e)[:100]
77 + raise RuntimeError(err)
78 + batches = [todo[i:i + 256] for i in range(0, len(todo), 256)]
79 + with cf.ThreadPoolExecutor(8) as ex:
80 + for j, (idx, vecs, tok) in enumerate(ex.map(batch, batches)):
81 + for i, v in zip(idx, vecs): done[uids[i]] = np.asarray(v, np.float32)
82 + usage += tok
83 + if j % 200 == 199: log.info("%s/%s batches %.0fs", j + 1, len(batches), time.time() - t0)
84 + M = np.stack([done[u] for u in uids]); np.save(EMB, M); pd.DataFrame({"uid": uids}).to_csv(EMB_IDX, index=False)
85 + ledger("category_model.embed", model="text-embedding-3-small", listings=len(uids), tokens=usage); log.info("embeddings saved %s tokens", usage)
86 +
87 +
88 +def train():
89 + from sklearn.linear_model import LogisticRegression
90 + from sklearn.model_selection import cross_val_predict
91 + M = np.load(EMB); uids = pd.read_csv(EMB_IDX).uid.tolist(); pos = {u: i for i, u in enumerate(uids)}
92 + li = pd.read_parquet(P["interim"] / "listings_clean.parquet").set_index("uid")
93 + cats = list(TAXO); onehot = lambda s: np.eye(len(cats))[[cats.index(c) if c in cats else cats.index("autre") for c in s]]
94 + tr = pd.read_csv(TRAIN); tr = tr[tr.llm.isin(cats)]
95 + Xtr = np.hstack([M[[pos[u] for u in tr.uid]], onehot(tr.category)]); ytr = tr.llm.values
96 + clf = LogisticRegression(max_iter=2000, C=2.0, n_jobs=-1)
97 + cvp = cross_val_predict(clf, Xtr, ytr, cv=5); cv_acc = float((cvp == ytr).mean())
98 + clf.fit(Xtr, ytr)
99 + au = pd.read_csv(AUDIT); cons = au[au.llm_a == au.llm_b]; Xau = np.hstack([M[[pos[u] for u in cons.uid]], onehot(cons.category)]); pred_au = clf.predict(Xau)
100 + acc_au = float((pred_au == cons.llm_a).mean()); rule_au = float((cons.category == cons.llm_a).mean())
101 + # listing-weighted accuracy (audit sample is stratified 25/category -> reweight by assortment shares of the RULE label)
102 + a = li[li.f_analysis_assort]; w = a.category.value_counts(normalize=True); ww = cons.category.map(w).values; ww = ww / ww.sum()
103 + acc_au_w = float(((pred_au == cons.llm_a) * ww).sum()); rule_au_w = float(((cons.category == cons.llm_a) * ww).sum())
104 + per = {c: {"n": int((cons.llm_a == c).sum()), "recall_model": round(float((pred_au[cons.llm_a == c] == c).mean()), 3) if (cons.llm_a == c).any() else None, "precision_model": round(float((cons.llm_a[pred_au == c] == c).mean()), 3) if (pred_au == c).any() else None} for c in cats}
105 + conf = pd.crosstab(cons.llm_a.rename("annotated"), pd.Series(pred_au, index=cons.index, name="model"))
106 + # predict all assortment listings
107 + allu = a.index.tolist(); Xall = np.hstack([M[[pos[u] for u in allu]], onehot(a.category)]); prob = clf.predict_proba(Xall); pred = clf.classes_[prob.argmax(1)]
108 + out = pd.DataFrame({"uid": allu, "category_rule": a.category.values, "category_model": pred, "p_max": prob.max(1)})
109 + out.to_parquet(PROC / "category_model.parquet", index=False); register_output(PROC / "category_model.parquet", "category_model")
110 + res = {"train_n": int(len(tr)), "cv5_accuracy_vs_llm": round(cv_acc, 4), "audit_consensus_n": int(len(cons)), "accuracy_model_on_audit": round(acc_au, 4), "accuracy_rule_on_audit": round(rule_au, 4),
111 + "listing_weighted_accuracy_model": round(acc_au_w, 4), "listing_weighted_accuracy_rule": round(rule_au_w, 4), "share_relabelled": round(float((out.category_rule != out.category_model).mean()), 4),
112 + "category_shares_rule": a.category.value_counts(normalize=True).round(4).to_dict(), "category_shares_model": out.category_model.value_counts(normalize=True).round(4).to_dict(),
113 + "per_category_on_audit": per, "confusion_audit_annotated_vs_model": {r: {c: int(v) for c, v in row.items() if v} for r, row in conf.to_dict("index").items()}, "features": "text-embedding-3-small (512 d) + one-hot rule label; multinomial logistic regression C=2"}
114 + write_json(res, BASE_EXP / "category_model.json", "category_model"); ledger("category_model.train", **{k: v for k, v in res.items() if "per_" not in k and "confusion" not in k and "shares" not in k})
115 + log.info(json.dumps({k: v for k, v in res.items() if "per_" not in k and "confusion" not in k and "shares" not in k}))
116 +
117 +
118 +if __name__ == "__main__":
119 + {"sample": sample, "annotate": annotate, "embed": embed, "train": train}[sys.argv[1]]()
modified src/features/diversity.py +3 −3
@@ -3,10 +3,10 @@ HHI of category shares, rarefied entropy at m = 15 stores (2 000 draws), store-b
3 3 from __future__ import annotations
4 4 import json
5 5 import numpy as np, pandas as pd
6 −from src.common import ROOT, SEED, get_logger, ledger, register_output, write_json
6 +from src.common import ROOT, SEED, get_logger, ledger, register_output, write_json, PROC_DIR, EXP_DIR, P
7 7
8 8 log = get_logger("features.diversity")
9 −PROC = ROOT / "data" / "processed"
9 +PROC = PROC_DIR
10 10 M_RAREFY, NB = 15, 2000
11 11
12 12
@@ -43,7 +43,7 @@ def main():
43 43 allq = region_stats(d, "M"); store_cats = sc.groupby("store_id").category.nunique()
44 44 summ = {"quebec_H_norm_M": round(allq["H_norm"], 4), "quebec_eff_cat_M": round(allq["eff_cat"], 3), "regions_rarefied": int(out.H_rarefied15.notna().sum()),
45 45 "store_n_categories_dist": store_cats.value_counts().sort_index().to_dict(), "range_H_rarefied15": [round(float(out.H_rarefied15.min()), 3), round(float(out.H_rarefied15.max()), 3)]}
46 − write_json(summ, ROOT / "experiments" / "diversity.json", "diversity"); ledger("features.diversity", **summ); log.info(json.dumps(summ))
46 + write_json(summ, EXP_DIR / "diversity.json", "diversity"); ledger("features.diversity", **summ); log.info(json.dumps(summ))
47 47
48 48
49 49 if __name__ == "__main__":
modified src/features/match_products.py +2 −2
@@ -18,7 +18,7 @@ from __future__ import annotations
18 18 import itertools, json, re, unicodedata
19 19 import numpy as np, pandas as pd, networkx as nx
20 20 from rapidfuzz import fuzz
21 −from src.common import ROOT, P, get_logger, ledger, register_output, write_json
21 +from src.common import ROOT, P, get_logger, ledger, register_output, write_json, BASE_PROC, BASE_EXP
22 22 from src.clean.listings import norm_title
23 23
24 24 log = get_logger("features.match")
@@ -123,7 +123,7 @@ def main():
123 123 "groups_AC": int(gs.shape[0]), "groups_AC_multi_store": int((gs.n_stores >= 2).sum()), "listings_in_groups": len(gr),
124 124 "stores_in_groups": int(gr.store_id.nunique()), "groups_size_gt_10": int((gs.n > 10).sum()),
125 125 "regions_pairs": None}
126 − write_json(summ, ROOT / "experiments" / "matching.json", "match"); ledger("features.match_products", **summ)
126 + write_json(summ, BASE_EXP / "matching.json", "match"); ledger("features.match_products", **summ)
127 127 log.info(json.dumps(summ, ensure_ascii=False))
128 128
129 129
modified src/features/region_category.py +6 −3
@@ -6,16 +6,19 @@ plus store-level table data/processed/store_panel.parquet used by every model (o
6 6 from __future__ import annotations
7 7 import json
8 8 import numpy as np, pandas as pd
9 −from src.common import ROOT, P, REGIONS, get_logger, ledger, register_output, write_json
9 +from src.common import ROOT, P, REGIONS, get_logger, ledger, register_output, write_json, PROC_DIR, EXP_DIR, LABEL
10 10
11 11 log = get_logger("features.rc")
12 −PROC = ROOT / "data" / "processed"
12 +PROC = PROC_DIR
13 13
14 14
15 15 def store_category_table():
16 16 li = pd.read_parquet(P["interim"] / "listings_clean.parquet")
17 17 sm = pd.read_parquet(P["interim"] / "stores_master.parquet")
18 18 a = li[li.f_analysis_assort]
19 + if LABEL == "model":
20 + cm = pd.read_parquet(BASE_PROC / "category_model.parquet").set_index("uid").category_model
21 + a = a.assign(category=a.uid.map(cm).fillna(a.category))
19 22 sc = a.groupby(["store_id", "category"]).size().rename("n").reset_index()
20 23 sc = sc.merge(sm[["id", "region_final", "region_source", "n_listings", "platform", "store_kind", "certified", "first_source", "origin_class"]], left_on="store_id", right_on="id").drop(columns="id")
21 24 sc = sc.merge(sm[["id", "region_dbfirst", "region_db"]], left_on="store_id", right_on="id").drop(columns="id")
@@ -53,7 +56,7 @@ def main():
53 56 "listings_S_R": int(rc.query("sample=='S_R' and scheme=='L'").n.sum()), "listings_S_A": int(rc.query("sample=='S_A' and scheme=='L'").n.sum()),
54 57 "top1pct_cut": float(sc.groupby('store_id').n.sum().quantile(0.99)),
55 58 "stores_by_region_S_R": sp[sp.region_final != "Inconnue"].region_final.value_counts().to_dict()}
56 − write_json(summ, ROOT / "experiments" / "features_rc.json", "features.rc"); ledger("features.region_category", **summ); log.info(json.dumps(summ, ensure_ascii=False))
59 + write_json(summ, EXP_DIR / "features_rc.json", "features.rc"); ledger("features.region_category", **summ); log.info(json.dumps(summ, ensure_ascii=False))
57 60
58 61
59 62 if __name__ == "__main__":
modified src/models/h1_raa.py +3 −3
@@ -5,10 +5,10 @@ Outputs experiments/h1_raa.json, data/processed/raa_matrix.csv (point estimates
5 5 from __future__ import annotations
6 6 import json
7 7 import numpy as np, pandas as pd
8 −from src.common import ROOT, P, REGIONS, SEED, get_logger, ledger, register_output, write_json
8 +from src.common import ROOT, P, REGIONS, SEED, get_logger, ledger, register_output, write_json, PROC_DIR, EXP_DIR
9 9
10 10 log = get_logger("models.h1")
11 −PROC = ROOT / "data" / "processed"
11 +PROC = PROC_DIR
12 12 N_PERM, N_BOOT = 2000, 2000
13 13 CATS = None
14 14
@@ -83,7 +83,7 @@ def main():
83 83 res_m, mat_m = run(d, "S_R", "L", rng); res_m["scheme"] = "stores"; mat_m["scheme"] = "stores"
84 84 results.append(res_m); mats.append(mat_m)
85 85 pd.concat(mats).to_csv(PROC / "raa_matrix.csv", index=False); register_output(PROC / "raa_matrix.csv", "h1")
86 − write_json({"n_perm": N_PERM, "n_boot": N_BOOT, "results": results}, ROOT / "experiments" / "h1_raa.json", "h1"); ledger("models.h1_raa", results=results)
86 + write_json({"n_perm": N_PERM, "n_boot": N_BOOT, "results": results}, EXP_DIR / "h1_raa.json", "h1"); ledger("models.h1_raa", results=results)
87 87
88 88
89 89 if __name__ == "__main__":
modified src/models/h2_scale.py +3 −3
@@ -6,10 +6,10 @@ from __future__ import annotations
6 6 import json
7 7 import numpy as np, pandas as pd
8 8 from scipy import stats
9 −from src.common import ROOT, SEED, get_logger, ledger, write_json
9 +from src.common import ROOT, SEED, get_logger, ledger, write_json, PROC_DIR, EXP_DIR, P
10 10
11 11 log = get_logger("models.h2")
12 −PROC = ROOT / "data" / "processed"
12 +PROC = PROC_DIR
13 13 COV = {"log_population": ("population", "ISQ population estimate, 1 July 2025 (provisional)"), "log_business_locations": ("business_locations_with_employees", "ISQ business locations with employees, Dec 2025"),
14 14 "disposable_income": ("disposable_income_per_capita_cad", "ISQ disposable income per capita, 2024p"), "log_density": ("population_density_per_km2", "Census 2021 population density (2021 ER boundaries)")}
15 15
@@ -59,7 +59,7 @@ def main():
59 59 xd = e.join(ind[["diversity"]], rsuffix="_i"); v3["partial_corr_eci_diversity_given_covariates"] = round(float(stats.spearmanr(y - X @ b, xd.diversity - X @ np.linalg.lstsq(X, xd.diversity.values, rcond=None)[0]).correlation), 3)
60 60 out["H7_V3"] = v3
61 61 df.reset_index().rename(columns={"index": "region"}).to_csv(PROC / "h2_region_table.csv", index=False)
62 − write_json(out, ROOT / "experiments" / "h2_scale.json", "h2"); ledger("models.h2_scale", **{k: v for k, v in out.items() if k != "correlations"}, primary=out["correlations"].get("H_rarefied15 ~ log_population"))
62 + write_json(out, EXP_DIR / "h2_scale.json", "h2"); ledger("models.h2_scale", **{k: v for k, v in out.items() if k != "correlations"}, primary=out["correlations"].get("H_rarefied15 ~ log_population"))
63 63 log.info(json.dumps({k: v for k, v in out["correlations"].items() if "H_rarefied15" in k or "per_100k" in k})); log.info(json.dumps(v3))
64 64
65 65
modified src/models/h3_longtail.py +3 −3
@@ -6,10 +6,10 @@ from __future__ import annotations
6 6 import json
7 7 import numpy as np, pandas as pd
8 8 from scipy import stats, optimize, special
9 −from src.common import ROOT, SEED, get_logger, ledger, write_json
9 +from src.common import ROOT, SEED, get_logger, ledger, write_json, PROC_DIR, EXP_DIR, P
10 10
11 11 log = get_logger("models.h3")
12 −PROC = ROOT / "data" / "processed"
12 +PROC = PROC_DIR
13 13
14 14
15 15 def gini(x):
@@ -75,7 +75,7 @@ def main():
75 75 "top10pct_share": round(top_share(v, 0.10), 3), "median": float(np.median(v))}
76 76 res["within_region"] = wr
77 77 pd.DataFrame({"rank": ranks, "n_listings": xs}).to_csv(PROC / "rank_size.csv", index=False)
78 − write_json(res, ROOT / "experiments" / "h3_longtail.json", "h3"); ledger("models.h3_longtail", **{k: v for k, v in res.items() if k != "within_region"})
78 + write_json(res, EXP_DIR / "h3_longtail.json", "h3"); ledger("models.h3_longtail", **{k: v for k, v in res.items() if k != "within_region"})
79 79 log.info(json.dumps({k: v for k, v in res.items() if k != "within_region"}))
80 80
81 81
modified src/models/h4_dynamics.py +6 −3
@@ -8,10 +8,10 @@ status change or an 'added' burst in the outcome window.
8 8 from __future__ import annotations
9 9 import json
10 10 import numpy as np, pandas as pd
11 −from src.common import ROOT, P, SEED, get_logger, ledger, register_output, write_json
11 +from src.common import ROOT, P, SEED, get_logger, ledger, register_output, write_json, PROC_DIR, EXP_DIR, BASE_PROC, BASE_EXP, LABEL
12 12
13 13 log = get_logger("models.h4")
14 −PROC = ROOT / "data" / "processed"
14 +PROC = PROC_DIR
15 15 BASE = (pd.Timestamp("2026-08-31", tz="UTC"), pd.Timestamp("2026-09-28", tz="UTC"))
16 16 OUT = (pd.Timestamp("2026-09-28", tz="UTC"), pd.Timestamp("2026-11-02", tz="UTC"))
17 17
@@ -46,6 +46,9 @@ def main():
46 46 rng = np.random.default_rng(SEED)
47 47 li = pd.read_parquet(P["interim"] / "listings_clean.parquet"); li = li[li.f_published | (li.active == 0)] # all live-store listings incl. inactive
48 48 li = li[li.f_live_store & ~li.f_hors_mission & ~li.f_sibling_dup]
49 + if LABEL == "model":
50 + cm = pd.read_parquet(BASE_PROC / "category_model.parquet").set_index("uid").category_model
51 + li = li.assign(category=li.uid.map(cm).fillna(li.category))
49 52 sp = pd.read_parquet(PROC / "store_panel.parquet").set_index("id")
50 53 base = present(li, *BASE); out = present(li, *OUT)
51 54 phi, pv = proximity(base)
@@ -87,7 +90,7 @@ def main():
87 90 "region_level_entries": region_entries, "region_level_exits": region_exits, "n_region_entries": len(region_entries),
88 91 "share_entering_stores_with_added_burst": share_burst}
89 92 D.to_parquet(PROC / "h4_panel.parquet", index=False); register_output(PROC / "h4_panel.parquet", "h4")
90 − write_json(res, ROOT / "experiments" / "h4_dynamics.json", "h4"); ledger("models.h4_dynamics", **{k: v for k, v in res.items() if "region_level" not in k})
93 + write_json(res, EXP_DIR / "h4_dynamics.json", "h4"); ledger("models.h4_dynamics", **{k: v for k, v in res.items() if "region_level" not in k})
91 94 log.info(json.dumps({k: v for k, v in res.items() if "region_level" not in k}))
92 95
93 96
modified src/models/h5_hedonic.py +3 −3
@@ -10,10 +10,10 @@ Outputs experiments/h5_hedonic.json, data/processed/h5_groups.parquet, data/proc
10 10 from __future__ import annotations
11 11 import json
12 12 import numpy as np, pandas as pd
13 −from src.common import ROOT, P, SEED, get_logger, ledger, register_output, write_json
13 +from src.common import ROOT, P, SEED, get_logger, ledger, register_output, write_json, PROC_DIR, EXP_DIR
14 14
15 15 log = get_logger("models.h5")
16 −PROC = ROOT / "data" / "processed"
16 +PROC = PROC_DIR
17 17 B = 9999
18 18
19 19
@@ -120,7 +120,7 @@ def main():
120 120 g = s.assign(r=r).groupby("store_id").apply(lambda h: (h.x * h.r).sum()); se = float(np.sqrt((g ** 2).sum()) / (s.x ** 2).sum())
121 121 cert = {"note": "descriptive store-certification contrast (Aliments du Québec / Les Produits du Québec), category FE, store-clustered SE — NOT a test of H6",
122 122 "obs": int(len(s)), "stores": int(s.store_id.nunique()), "certified_stores": int(s.groupby('store_id').cert.first().sum()), "b_log_price": round(b, 4), "se": round(se, 4)}
123 − write_json({"models": out, "certification_contrast": cert}, ROOT / "experiments" / "h5_hedonic.json", "h5"); ledger("models.h5_hedonic", models=[{k: v for k, v in m.items() if k not in ("coef", "stores_by_region")} for m in out], cert=cert)
123 + write_json({"models": out, "certification_contrast": cert}, EXP_DIR / "h5_hedonic.json", "h5"); ledger("models.h5_hedonic", models=[{k: v for k, v in m.items() if k not in ("coef", "stores_by_region")} for m in out], cert=cert)
124 124 log.info(json.dumps(cert))
125 125
126 126
modified src/models/h7_complexity.py +3 −3
@@ -8,11 +8,11 @@ from __future__ import annotations
8 8 import json
9 9 import numpy as np, pandas as pd, networkx as nx
10 10 from scipy import stats
11 −from src.common import ROOT, SEED, get_logger, ledger, register_output, write_json
11 +from src.common import ROOT, SEED, get_logger, ledger, register_output, write_json, PROC_DIR, EXP_DIR, P
12 12 from src.models.h1_raa import weights, table, raa
13 13
14 14 log = get_logger("models.h7")
15 −PROC = ROOT / "data" / "processed"
15 +PROC = PROC_DIR
16 16
17 17
18 18 def eci(M):
@@ -111,7 +111,7 @@ def main():
111 111 "obs_corr_eci_diversity": round(obs_corr_div, 3), "null_corr_eci_diversity_p5_p95": [round(float(np.nanpercentile(null_corr_div, 5)), 3), round(float(np.nanpercentile(null_corr_div, 95)), 3)],
112 112 "pass": bool(p_l2 < 0.05)},
113 113 "V4_community_stability": v4, "mst_edges": [(a, b, round(w, 3)) for a, b, w in mst.edges(data="weight")]}
114 − write_json(res, ROOT / "experiments" / "h7_complexity.json", "h7"); ledger("models.h7_complexity", **{k: v for k, v in res.items() if k not in ("mst_edges",)})
114 + write_json(res, EXP_DIR / "h7_complexity.json", "h7"); ledger("models.h7_complexity", **{k: v for k, v in res.items() if k not in ("mst_edges",)})
115 115 log.info(json.dumps({k: v for k, v in res.items() if k in ("M_density", "V1_bootstrap_rank_stability", "V2_reshuffle_null", "V4_community_stability")}, ensure_ascii=False)[:900])
116 116
117 117
modified src/models/qdaci.py +4 −4
@@ -7,12 +7,12 @@ from __future__ import annotations
7 7 import json
8 8 import numpy as np, pandas as pd
9 9 from scipy import stats
10 −from src.common import ROOT, SEED, get_logger, ledger, register_output, write_json
10 +from src.common import ROOT, SEED, get_logger, ledger, register_output, write_json, PROC_DIR, EXP_DIR, P
11 11 from src.models.h1_raa import weights, table, raa
12 12 from src.models.h3_longtail import gini
13 13
14 14 log = get_logger("models.qdaci")
15 −PROC = ROOT / "data" / "processed"
15 +PROC = PROC_DIR
16 16
17 17
18 18 def spec_index(d, regions, cats):
@@ -25,7 +25,7 @@ def main():
25 25 rng = np.random.default_rng(SEED)
26 26 sc = pd.read_parquet(PROC / "store_category.parquet"); d = sc[sc.region_final != "Inconnue"].reset_index(drop=True)
27 27 regions = sorted(d.region_final.unique()); cats = sorted(d.category.unique())
28 − div = pd.read_csv(PROC / "diversity_region.csv").set_index("region"); h7 = json.load(open(ROOT / "experiments" / "h7_complexity.json"))
28 + div = pd.read_csv(PROC / "diversity_region.csv").set_index("region"); h7 = json.load(open(EXP_DIR / "h7_complexity.json"))
29 29 ext = pd.read_csv(ROOT / "data" / "external" / "regions_external.csv").set_index("region_name"); sp = pd.read_parquet(PROC / "store_panel.parquet")
30 30 spec = pd.Series(spec_index(d, regions, cats), index=regions)
31 31 # bootstrap specialization
@@ -62,7 +62,7 @@ def main():
62 62 "publish_composite": bool(np.median(rhos) >= 0.7), "note": "Components describe observed online catalogues of Quebec-based merchants, not production or output. Complexity omitted because the ECI failed the preregistered stability criterion." if not include_eci else "Components describe observed online catalogues, not production."}
63 63 comp["composite_equal"] = eq.reindex(regions); comp["composite_pca"] = pc.reindex(regions); comp["composite_borda"] = borda.reindex(regions)
64 64 comp.reset_index().rename(columns={"index": "region"}).to_csv(PROC / "qdaci_components.csv", index=False); register_output(PROC / "qdaci_components.csv", "qdaci")
65 − write_json(res, ROOT / "experiments" / "qdaci.json", "qdaci"); ledger("models.qdaci", **res); log.info(json.dumps(res))
65 + write_json(res, EXP_DIR / "qdaci.json", "qdaci"); ledger("models.qdaci", **res); log.info(json.dumps(res))
66 66
67 67
68 68 if __name__ == "__main__":
added src/models/summary.py +55 −0
@@ -0,0 +1,55 @@
1 +"""Hypothesis registry: confirmatory family {H1, H2, H3, H5-T1, H5-T2} with Holm step-down; exploratory/validation outputs listed with status.
2 +Writes experiments/hypothesis_registry.json and reports/hypothesis_registry.md."""
3 +from __future__ import annotations
4 +import json
5 +import numpy as np
6 +from src.common import ROOT, get_logger, write_json, PROC_DIR, EXP_DIR, P
7 +
8 +log = get_logger("models.summary"); EXP = EXP_DIR
9 +J = lambda n: json.load(open(EXP / n))
10 +
11 +
12 +def holm(p):
13 + keys = sorted(p, key=lambda k: p[k]); m = len(keys); adj = {}; running = 0
14 + for i, k in enumerate(keys):
15 + running = max(running, (m - i) * p[k]); adj[k] = min(1.0, running)
16 + return adj
17 +
18 +
19 +def main(label="rule"):
20 + h1 = {(r["sample"], r["scheme"]): r for r in J("h1_raa.json")["results"]}; h2 = J("h2_scale.json"); h3 = J("h3_longtail.json"); h5 = J("h5_hedonic.json")["models"][0]; h7 = J("h7_complexity.json"); h4 = J("h4_dynamics.json"); q = J("qdaci.json")
21 + pM, pT, pL = h1[("S_R", "M")]["p_perm"], h1[("S_R", "T")]["p_perm"], h1[("S_R", "L")]["p_perm"]
22 + c2 = h2["correlations"]["H_rarefied15 ~ log_population"]
23 + raw = {"H1": max(pM, pT), "H2": None, "H3": None, "H5-T1": h5["p_region_wildboot"], "H5-T2": h5["p_reseller_wildboot"]}
24 + # H2 and H3 have CI/threshold decisions; for Holm we convert H2 to a two-sided permutation-free p via Fisher z (reported as approximate)
25 + n2 = c2["n"]; z = np.arctanh(c2["rho"]) * np.sqrt(max(n2 - 3, 1)); from scipy.stats import norm; raw["H2"] = float(2 * (1 - norm.cdf(abs(z))))
26 + raw["H3"] = 0.0 if h3["decision_prereg"] else 1.0 # threshold criterion with bootstrap CIs; p-value placeholder for the Holm ordering only
27 + adj = holm({k: v for k, v in raw.items()})
28 + reg = [
29 + {"id": "H1", "statement": "Online assortment composition differs across regions beyond what merchant counts imply", "primary_statistic": f"Cramér's V (M) = {h1[('S_R','M')]['cramers_v']:.3f}, store-reshuffle p = {pM:.4f}; (T) V = {h1[('S_R','T')]['cramers_v']:.3f}, p = {pT:.3f}; (L) p = {pL:.3f}", "p_raw": raw["H1"], "p_holm": adj["H1"],
30 + "status": "partially supported" if pM < 0.05 and pT >= 0.05 else ("supported" if adj["H1"] < 0.05 else "not supported"), "note": "Preregistered criterion required p < 0.05 under both merchant-equal and trimmed-listing weights; met for merchant-equal only. Specialization is a property of the merchant population, not of listing volumes."},
31 + {"id": "H2", "statement": "Regional diversity is associated with regional scale", "primary_statistic": f"Spearman rho(rarefied entropy, log population) = {c2['rho']:.2f}, 95% CI [{c2['ci'][0]:.2f}; {c2['ci'][1]:.2f}], n = {c2['n']}", "p_raw": raw["H2"], "p_holm": adj["H2"],
32 + "status": "inconclusive" if c2["ci"][0] < 0 < c2["ci"][1] else "supported", "note": "CI includes zero and economically meaningful values in both directions; n = 16 regions. No association with business counts or density either (see table)."},
33 + {"id": "H3", "statement": "Merchant catalogue sizes are highly skewed", "primary_statistic": f"Gini = {h3['gini']:.3f} [{h3['gini_ci'][0]:.3f}; {h3['gini_ci'][1]:.3f}]; top-10% share = {h3['top10pct_share']:.3f} [{h3['top10_ci'][0]:.3f}; {h3['top10_ci'][1]:.3f}]", "p_raw": None, "p_holm": None,
34 + "status": "supported" if h3["decision_prereg"] else "not supported", "note": "Both preregistered thresholds (Gini >= 0.70, top-10% >= 0.50) exceeded with CIs entirely above them; power-law tail favoured over log-normal on the tail."},
35 + {"id": "H4", "statement": "Related diversification (exploratory)", "primary_statistic": f"LPM density coefficient {h4['lpm_density_coef']:.3f} (SE {h4['se_cluster_store']:.3f}), placebo p = {h4['placebo_p']:.3f}; {h4['entries']} entries; {len(h4['region_level_entries'])} region-level entries", "p_raw": None, "p_holm": None,
36 + "status": "exploratory: consistent with relatedness at the store level; region level untestable", "note": "Fewer than 10 region-level entries (preregistered untestability rule); store-level association over 5 weeks, no causal claim."},
37 + {"id": "H5-T1", "statement": "Matched-product prices are geographically structured", "primary_statistic": f"Wald({h5['df_region']}) = {h5['wald_region']:.1f}, wild-cluster-bootstrap p = {h5['p_region_wildboot']:.4f}; {h5['groups']} groups, {h5['stores']} stores, {h5['regions_with_ge5_stores']} regions with >= 5 stores", "p_raw": raw["H5-T1"], "p_holm": adj["H5-T1"],
38 + "status": "supported" if adj["H5-T1"] < 0.05 else "not supported", "note": "Region effects identified only through groups spanning regions; posted prices, no shipping/taxes."},
39 + {"id": "H5-T2", "statement": "Matched-product prices are competitively structured (reseller vs producer)", "primary_statistic": f"reseller coefficient {h5['coef']['reseller']['b']:.3f} (SE {h5['coef']['reseller']['se_cluster']:.3f}), wild-bootstrap p = {h5['p_reseller_wildboot']:.4f}", "p_raw": raw["H5-T2"], "p_holm": adj["H5-T2"],
40 + "status": "supported" if adj["H5-T2"] < 0.05 else "not supported", "note": "Resellers/collectives post lower prices than producers for the same product."},
41 + {"id": "H6", "statement": "Local-origin price premium", "primary_statistic": "not estimated", "p_raw": None, "p_holm": None, "status": "untestable (declared ex ante)", "note": "No product-level provenance; a store-certification contrast is reported descriptively and is not H6."},
42 + {"id": "H7", "statement": "Validated digital-assortment complexity indicator", "primary_statistic": f"ECI bootstrap rank stability median rho = {h7['V1_bootstrap_rank_stability']['eci_rho_median']:.2f} (criterion 0.8); lambda2 null p = {h7['V2_reshuffle_null']['p_lambda2']:.2f}; LOO predictive rho = {h2['H7_V3']['loo_pred_spearman']:.2f}", "p_raw": None, "p_holm": None,
43 + "status": "not validated: ECI unstable at 17 x 20; omitted from QDACI", "note": "Fitness and plain diversity are more stable but still below the criterion; the region-category matrix is not more nested than the store-reshuffle null."},
44 + {"id": "QDACI", "statement": "Composite index", "primary_statistic": f"median agreement across weightings rho = {q['composite_agreement_spearman']['median']:.2f} (rule 0.7); bootstrap rank stability {q['bootstrap_rank_stability_equal_weights_median_rho']:.2f}", "p_raw": None, "p_holm": None,
45 + "status": "components published; composite withheld" if not q["publish_composite"] else "composite published", "note": q["note"]},
46 + ]
47 + write_json({"labelling": label, "holm_family": list(raw), "registry": reg}, EXP / f"hypothesis_registry_{label}.json", "summary")
48 + md = [f"# Hypothesis registry ({label} category labelling)", "", "| id | statement | primary statistic | p raw | p Holm | status |", "|---|---|---|---|---|---|"]
49 + md += [f"| {r['id']} | {r['statement']} | {r['primary_statistic']} | {'' if r['p_raw'] is None else f'{r['p_raw']:.4f}'} | {'' if r['p_holm'] is None else f'{r['p_holm']:.4f}'} | **{r['status']}** |" for r in reg]
50 + md += ["", "Notes:"] + [f"- {r['id']}: {r['note']}" for r in reg]
51 + (ROOT / "reports" / f"hypothesis_registry_{label}.md").write_text("\n".join(md)); log.info("\n".join(f"{r['id']}: {r['status']}" for r in reg))
52 +
53 +
54 +if __name__ == "__main__":
55 + import sys; main(sys.argv[1] if len(sys.argv) > 1 else "rule")
modified src/reports/make_figures.py +8 −8
@@ -6,10 +6,10 @@ import numpy as np, pandas as pd, matplotlib
6 6 matplotlib.use("Agg")
7 7 import matplotlib.pyplot as plt
8 8 from matplotlib.colors import TwoSlopeNorm, LinearSegmentedColormap
9 −from src.common import ROOT, P, REGIONS, get_logger, ledger
9 +from src.common import ROOT, P, REGIONS, get_logger, ledger, PROC_DIR, EXP_DIR, BASE_PROC, BASE_EXP
10 10
11 11 log = get_logger("reports.figures")
12 −PROC = ROOT / "data" / "processed"; FIG = ROOT / "paper" / "figures"; FIG.mkdir(exist_ok=True)
12 +PROC = PROC_DIR; FIG = P["figures"]; FIG.mkdir(exist_ok=True)
13 13 C = ["#2a78d6", "#eb6834", "#1baf7a", "#eda100", "#e87ba4"]; GRAY = "#8a8a85"
14 14 SEQ = LinearSegmentedColormap.from_list("seqblue", ["#cde2fb", "#2a78d6", "#0d366b"])
15 15 DIV = LinearSegmentedColormap.from_list("divob", ["#eb6834", "#f2f2ef", "#2a78d6"])
@@ -44,7 +44,7 @@ def fig_raa_heatmap():
44 44
45 45
46 46 def fig_rank_size():
47 − rs = pd.read_csv(PROC / "rank_size.csv"); h3 = json.load(open(ROOT / "experiments" / "h3_longtail.json"))
47 + rs = pd.read_csv(PROC / "rank_size.csv"); h3 = json.load(open(EXP_DIR / "h3_longtail.json"))
48 48 fig, ax = plt.subplots(figsize=(5.2, 3.8)); ax.loglog(rs["rank"], rs.n_listings, color=C[0], lw=1.6)
49 49 pl = h3["powerlaw"]; xm = pl["xmin"]; ax.axhline(xm, color=GRAY, lw=0.8, ls="--"); ax.text(1.2, xm * 1.15, f"power-law tail from {xm} listings (α = {pl['alpha']:.2f})", color="#444", fontsize=8)
50 50 ax.set_xlabel("Store rank (by catalogue size)"); ax.set_ylabel("Listings in the assortment sample")
@@ -82,7 +82,7 @@ def fig_region_effects():
82 82
83 83 def fig_network():
84 84 import networkx as nx
85 − h7 = json.load(open(ROOT / "experiments" / "h7_complexity.json")); phi = pd.read_csv(PROC / "category_proximity.csv", index_col=0)
85 + h7 = json.load(open(EXP_DIR / "h7_complexity.json")); phi = pd.read_csv(PROC / "category_proximity.csv", index_col=0)
86 86 G = nx.Graph()
87 87 for a in phi.index:
88 88 for b in phi.columns:
@@ -98,7 +98,7 @@ def fig_network():
98 98
99 99
100 100 def fig_attrition():
101 − flow = json.load(open(ROOT / "experiments" / "sample_flow.json"))["flow"]
101 + flow = json.load(open(BASE_EXP / "sample_flow.json"))["flow"]
102 102 fig, ax = plt.subplots(figsize=(7.4, 3.2)); labels = [k.replace("… ", "").replace(" [ASSORTMENT SAMPLE]", "").replace(" [PRICE SAMPLE]", "") for k, _ in flow]; vals = [v for _, v in flow]
103 103 y = np.arange(len(vals))[::-1]; ax.barh(y, vals, color=[C[0] if "SAMPLE" in k else "#9ec5f4" for k, _ in flow], height=0.6)
104 104 for yi, v, (k, _) in zip(y, vals, flow): ax.text(v + 3000, yi, f"{v:,}".replace(",", " "), va="center", fontsize=8)
@@ -107,7 +107,7 @@ def fig_attrition():
107 107
108 108
109 109 def fig_h7():
110 − h7 = json.load(open(ROOT / "experiments" / "h7_complexity.json")); ind = pd.read_csv(PROC / "h7_region_indicators.csv")
110 + h7 = json.load(open(EXP_DIR / "h7_complexity.json")); ind = pd.read_csv(PROC / "h7_region_indicators.csv")
111 111 fig, axes = plt.subplots(1, 2, figsize=(8, 3.4))
112 112 axes[0].scatter(ind.diversity, ind.eci, color=C[0], s=28)
113 113 for _, r in ind.iterrows(): axes[0].annotate(SHORT.get(r.region, r.region), (r.diversity, r.eci), fontsize=6.5, xytext=(3, 2), textcoords="offset points")
@@ -118,7 +118,7 @@ def fig_h7():
118 118
119 119
120 120 def fig_h4():
121 − h4 = json.load(open(ROOT / "experiments" / "h4_dynamics.json")); d = pd.read_parquet(PROC / "h4_panel.parquet")
121 + h4 = json.load(open(EXP_DIR / "h4_dynamics.json")); d = pd.read_parquet(PROC / "h4_panel.parquet")
122 122 d["bin"] = pd.qcut(d.density, 10, labels=False, duplicates="drop"); g = d.groupby("bin").agg(x=("density", "mean"), y=("entry", "mean"), n=("entry", "size"))
123 123 fig, ax = plt.subplots(figsize=(5, 3.4)); ax.scatter(g.x, g.y, color=C[0], s=30); ax.set_xlabel("Relatedness density of the absent category (baseline weeks)"); ax.set_ylabel("Share of (store, category) cells with entry")
124 124 ax.set_title(f"LPM slope {h4['lpm_density_coef']:.3f} (cluster SE {h4['se_cluster_store']:.3f}); placebo 95th pct {h4['placebo_p95']:.3f}", fontsize=8)
@@ -126,7 +126,7 @@ def fig_h4():
126 126
127 127
128 128 def fig_robustness():
129 − rb = json.load(open(ROOT / "experiments" / "robustness.json")); keys = [k for k in rb if isinstance(rb[k], dict) and "h1_M" in rb[k]]
129 + rb = json.load(open(EXP_DIR / "robustness.json")); keys = [k for k in rb if isinstance(rb[k], dict) and "h1_M" in rb[k]]
130 130 fig, ax = plt.subplots(figsize=(6.2, 3.8)); y = np.arange(len(keys))[::-1]
131 131 for j, (sch, col, lab) in enumerate((("h1_M", C[0], "merchant-equal (M)"), ("h1_T", C[1], "trimmed listings (T)"))):
132 132 v = [rb[k][sch]["V"] for k in keys]; nm = [rb[k][sch]["null_mean"] for k in keys]
modified src/reports/make_tables.py +9 −9
@@ -2,10 +2,10 @@
2 2 from __future__ import annotations
3 3 import json
4 4 import numpy as np, pandas as pd
5 −from src.common import ROOT, get_logger, ledger
5 +from src.common import ROOT, get_logger, ledger, PROC_DIR, EXP_DIR, P, BASE_PROC, BASE_EXP
6 6
7 7 log = get_logger("reports.tables")
8 −PROC = ROOT / "data" / "processed"; TAB = ROOT / "paper" / "tables"; TAB.mkdir(exist_ok=True); EXP = ROOT / "experiments"
8 +PROC = PROC_DIR; TAB = P["tables"]; TAB.mkdir(exist_ok=True); EXP = EXP_DIR
9 9 SHORT = {"Saguenay–Lac-Saint-Jean": "Saguenay--Lac-Saint-Jean", "Gaspésie–Îles-de-la-Madeleine": "Gaspésie--Îles-de-la-Madeleine"}
10 10 CAT = {"erable": "Maple \\& honey", "epicerie": "Fine grocery", "cafe_the": "Coffee \\& tea", "chocolat": "Chocolate", "boulangerie": "Bakery", "viande_poisson": "Meat \\& fish", "fromage_laitier": "Cheese \\& dairy", "boissons": "Beverages", "alcool": "Alcohol", "sante_beaute": "Beauty \\& care", "maison": "Home \\& decor", "mode": "Fashion", "bijoux": "Jewellery", "art": "Art \\& craft", "enfants": "Children", "animaux": "Pets", "plein_air": "Outdoor \\& sport", "jardin": "Garden", "industriel": "Industrial", "autre": "Other"}
11 11
@@ -24,7 +24,7 @@ def write(name, header, rows, caption, label, notes="", align=None, size="\\smal
24 24
25 25
26 26 def t_sample_flow():
27 − f = J("sample_flow.json")["flow"]
27 + f = json.load(open(BASE_EXP / "sample_flow.json"))["flow"]
28 28 write("sample_flow", ["Step", "Listings"], [[esc(k.replace("…", "\\ldots").replace("[", "\\textbf{[").replace("]", "]}")), n(v)] for k, v in f],
29 29 "Sample flow from database rows to the analysis samples", "sample_flow", "Snapshot of 2026-11-01T17:38Z. Nothing is deleted: each step is a flag in the cleaned listing table. The assortment sample is used for H1--H4 and H7; the price sample for H5.")
30 30
@@ -113,19 +113,19 @@ def t_h4():
113 113
114 114
115 115 def t_audits():
116 − p = EXP / "category_audit.json"
116 + p = BASE_EXP / "category_audit.json"
117 117 if p.exists():
118 − a = J("category_audit.json"); pc = a["per_category_precision_vs_consensus"]
118 + a = json.load(open(p)); pc = a["per_category_precision_vs_consensus"]
119 119 rows = [[CAT.get(k, k), v["n_consensus"], f"{v['precision']:.2f}" if v["precision"] is not None else "--"] for k, v in pc.items()]
120 120 rows.append(["\\textbf{All}", int(a["n"] * a["consensus_share"]), f"\\textbf{{{a['rule_precision_vs_consensus']:.2f}}}"])
121 121 write("category_audit", ["Rule label", "Listings with annotator consensus", "Precision of the rule label"], rows, "Audit of the rule-based category labels (500-listing stratified sample)", "cat_audit",
122 − f"25 random listings per category annotated independently by two LLMs (claude-opus-5-5, gpt-5.5) with the platform's taxonomy; agreement rule vs A {a['agree_category_llm_a']:.2f}, rule vs B {a['agree_category_llm_b']:.2f}, A vs B {a['agree_llm_a_llm_b']:.2f}; consensus exists for {a['consensus_share']:.0%} of listings. Precision = share of consensus labels equal to the rule label. Disagreements listed in the replication package.", size="\\footnotesize")
123 − p = EXP / "match_validation.json"
122 + f"25 random listings per category annotated independently by two LLMs (claude-opus-5-5, gpt-5.5) with the platform's taxonomy; agreement rule vs A {a['agree_category_llm_a']:.2f}, rule vs B {a['agree_category_llm_b']:.2f}, A vs B {a['agree_llm_a_llm_b']:.2f}; consensus exists for {100*a['consensus_share']:.0f}\\% of listings. Precision = share of consensus labels equal to the rule label. Disagreements listed in the replication package.", size="\\footnotesize")
123 + p = BASE_EXP / "match_validation.json"
124 124 if p.exists():
125 − m = J("match_validation.json"); pt = m["precision_by_tier_consensus"]
125 + m = json.load(open(p)); pt = m["precision_by_tier_consensus"]
126 126 rows = [[t, v["n"], f"{v['yes']:.2f}", f"{v['no']:.2f}", f"{m['precision_by_tier_comparable_only'].get(t, float('nan')):.2f}"] for t, v in pt.items()]
127 127 write("match_validation", ["Tier", "Pairs with consensus", "Same product", "Different", "Same product, comparable-variant pairs"], rows, "Validation of the cross-merchant matching tiers (60 pairs per tier)", "match_val",
128 − f"Two independent LLM annotators (agreement {m['agree_llm']:.2f}; consensus on {m['consensus_share']:.0%} of pairs). Tier A = shared merchant SKU; B = same brand and identical normalised title; C = same brand, fuzzy title; D = identical title without brand (never used for inference).", size="\\footnotesize")
128 + f"Two independent LLM annotators (agreement {m['agree_llm']:.2f}; consensus on {100*m['consensus_share']:.0f}\\% of pairs). Tier A = shared merchant SKU; B = same brand and identical normalised title; C = same brand, fuzzy title; D = identical title without brand (never used for inference).", size="\\footnotesize")
129 129
130 130
131 131 def t_h2():
modified src/reports/phase2_audit.py +2 −2
@@ -2,10 +2,10 @@
2 2 from __future__ import annotations
3 3 import json
4 4 import pandas as pd
5 −from src.common import ROOT, get_logger
5 +from src.common import ROOT, get_logger, BASE_PROC, BASE_EXP, P
6 6
7 7 log = get_logger("reports.phase2")
8 −EXP = ROOT / "experiments"
8 +EXP = BASE_EXP
9 9 J = lambda n: json.load(open(EXP / n)) if (EXP / n).exists() else {}
10 10
11 11
modified src/validation/category_audit.py +4 −4
@@ -13,11 +13,11 @@ from __future__ import annotations
13 13 import json, os, sys, time, concurrent.futures as cf
14 14 import pandas as pd
15 15 from dotenv import load_dotenv
16 −from src.common import ROOT, P, SEED, get_logger, ledger, register_output, write_json
16 +from src.common import ROOT, P, SEED, get_logger, ledger, register_output, write_json, BASE_PROC, BASE_EXP
17 17
18 18 log = get_logger("validation.category_audit")
19 19 load_dotenv(ROOT / ".env")
20 −SAMPLE = ROOT / "data" / "processed" / "category_audit_sample.csv"
20 +SAMPLE = BASE_PROC / "category_audit_sample.csv"
21 21 TAXO = {
22 22 "erable": "Érable & miel: sirop, beurre, flocons d'érable; miel, pollen, hydromel non alcoolisé",
23 23 "epicerie": "Épicerie fine: confitures, sauces, condiments, épices, vinaigres, huiles, farines, granolas, collations, marinades, produits d'épicerie en général (hors catégories plus spécifiques)",
@@ -129,9 +129,9 @@ def report():
129 129 conf = pd.crosstab(sub.category, sub.llm_a)
130 130 out["confusion_rule_vs_consensus"] = {r: {c: int(v) for c, v in row.items() if v} for r, row in conf.to_dict("index").items()}
131 131 dis = df[(df.category != df.llm_a) | (df.category != df.llm_b)]
132 − dis[["uid", "store_id", "category", "llm_a", "llm_b", "title", "product_type"]].to_csv(ROOT / "data" / "processed" / "category_audit_disagreements.csv", index=False)
132 + dis[["uid", "store_id", "category", "llm_a", "llm_b", "title", "product_type"]].to_csv(BASE_PROC / "category_audit_disagreements.csv", index=False)
133 133 out["n_disagreements"] = len(dis)
134 − write_json(out, ROOT / "experiments" / "category_audit.json", "category_audit.report")
134 + write_json(out, BASE_EXP / "category_audit.json", "category_audit.report")
135 135 log.info(json.dumps({k: v for k, v in out.items() if "per_" not in k and "confusion" not in k}))
136 136
137 137
modified src/validation/external_validation.py +3 −3
@@ -4,10 +4,10 @@ from __future__ import annotations
4 4 import json
5 5 import numpy as np, pandas as pd
6 6 from scipy import stats
7 −from src.common import ROOT, get_logger, ledger, write_json
7 +from src.common import ROOT, get_logger, ledger, write_json, PROC_DIR, EXP_DIR, P
8 8
9 9 log = get_logger("validation.external")
10 −PROC = ROOT / "data" / "processed"
10 +PROC = PROC_DIR
11 11
12 12
13 13 def main():
@@ -33,7 +33,7 @@ def main():
33 33 "source_mix_by_region": src.to_dict("index"), "platform_mix_by_region": plat.to_dict("index"), "category_mix_by_platform": catplat.to_dict("index"),
34 34 "note": "Business locations = ISQ, locations with employees, December 2025 (all industries; retail NAICS 44-45; manufacturing 31-33); population = ISQ 1 July 2025 provisional. Fabri-Ka stores are online storefronts of Quebec-based merchants discovered through certification registries, associations and search; coverage ratios are not sampling rates."}
35 35 src.to_csv(PROC / "source_mix_by_region.csv"); plat.to_csv(PROC / "platform_mix_by_region.csv"); catplat.to_csv(PROC / "category_mix_by_platform.csv")
36 − write_json(out, ROOT / "experiments" / "external_validation.json", "external"); ledger("validation.external", corr=corr, cv=cv)
36 + write_json(out, EXP_DIR / "external_validation.json", "external"); ledger("validation.external", corr=corr, cv=cv)
37 37 log.info(json.dumps({"corr": corr, "coverage": cv, "unknown": out["unknown_region"]}, ensure_ascii=False))
38 38
39 39
modified src/validation/match_validation.py +4 −4
@@ -10,11 +10,11 @@ from __future__ import annotations
10 10 import json, sys, time, concurrent.futures as cf
11 11 import pandas as pd
12 12 from dotenv import load_dotenv
13 −from src.common import ROOT, P, SEED, get_logger, ledger, write_json
13 +from src.common import ROOT, P, SEED, get_logger, ledger, write_json, BASE_PROC, BASE_EXP
14 14
15 15 log = get_logger("validation.match")
16 16 load_dotenv(ROOT / ".env")
17 −SAMPLE = ROOT / "data" / "processed" / "match_validation_sample.csv"
17 +SAMPLE = BASE_PROC / "match_validation_sample.csv"
18 18 SYSTEM = ("Tu es un expert en appariement de catalogues. On te montre deux fiches produit provenant de deux boutiques en ligne québécoises "
19 19 "différentes. Décide si elles décrivent le MÊME produit (même marque/fabricant, même article, même format/contenance ; des variantes de "
20 20 "couleur ou de taille du même article comptent comme le même produit si le prix affiché est le prix de base). Deux produits artisanaux "
@@ -93,8 +93,8 @@ def report():
93 93 if "operator" in df.columns:
94 94 out["precision_by_tier_operator"] = {t: round(float((df.loc[df.tier == t, "operator"] == "yes").mean()), 3) for t in sorted(df.tier.unique())}
95 95 dis = df[(df.llm_a != df.llm_b) | (df.llm_a == "unsure")]
96 − dis.to_csv(ROOT / "data" / "processed" / "match_validation_disagreements.csv", index=False); out["n_disagreements"] = len(dis)
97 − write_json(out, ROOT / "experiments" / "match_validation.json", "match_validation.report"); log.info(json.dumps(out))
96 + dis.to_csv(BASE_PROC / "match_validation_disagreements.csv", index=False); out["n_disagreements"] = len(dis)
97 + write_json(out, BASE_EXP / "match_validation.json", "match_validation.report"); log.info(json.dumps(out))
98 98
99 99
100 100 if __name__ == "__main__":
modified src/validation/robustness.py +4 −4
@@ -5,12 +5,12 @@ recomputed on (i) S_R0, (ii) certification/association sources only, (iii) exclu
5 5 from __future__ import annotations
6 6 import json
7 7 import numpy as np, pandas as pd
8 −from src.common import ROOT, SEED, get_logger, ledger, write_json
8 +from src.common import ROOT, SEED, get_logger, ledger, write_json, PROC_DIR, EXP_DIR, P, BASE_PROC, BASE_EXP
9 9 from src.models.h1_raa import weights, table, cramers_v
10 10 from src.models.h3_longtail import gini
11 11
12 12 log = get_logger("validation.robustness")
13 −PROC = ROOT / "data" / "processed"
13 +PROC = PROC_DIR
14 14 MACRO = {"erable": "food", "epicerie": "food", "cafe_the": "food", "chocolat": "food", "boulangerie": "food", "viande_poisson": "food", "fromage_laitier": "food", "boissons": "food",
15 15 "alcool": "alcohol", "sante_beaute": "beauty", "maison": "home", "mode": "fashion", "bijoux": "fashion", "art": "art", "enfants": "kids_pets", "animaux": "kids_pets",
16 16 "plein_air": "outdoor_garden", "jardin": "outdoor_garden", "industriel": "industrial", "autre": "other"}
@@ -51,7 +51,7 @@ def main():
51 51 for k, d in variants.items():
52 52 res[k] = {"h1_M": h1(d, rng, "M"), "h1_T": h1(d, rng, "T"), "entropy_M": ent_range(d), "gini_listings": round(gini(d.groupby("store_id").n.sum().values), 4)}
53 53 log.info("%s %s", k, json.dumps(res[k]))
54 − audit = ROOT / "experiments" / "category_audit.json"
54 + audit = BASE_EXP / "category_audit.json"
55 55 if audit.exists():
56 56 conf = json.load(open(audit)).get("confusion_rule_vs_consensus", {})
57 57 if conf:
@@ -67,7 +67,7 @@ def main():
67 67 d = d.groupby(["store_id", "category", "region_final", "region_source", "first_source", "store_kind"], as_index=False).n.sum()
68 68 Vs.append(h1(d, rng, "M", nperm=200))
69 69 res["confusion_reassignment_100draws"] = {"V_mean": round(float(np.mean([v["V"] for v in Vs])), 4), "V_min": round(float(min(v["V"] for v in Vs)), 4), "share_p_lt_05": round(float(np.mean([v["p"] < 0.05 for v in Vs])), 3)}
70 − write_json(res, ROOT / "experiments" / "robustness.json", "robustness"); ledger("validation.robustness", variants=list(res))
70 + write_json(res, EXP_DIR / "robustness.json", "robustness"); ledger("validation.robustness", variants=list(res))
71 71
72 72
73 73 if __name__ == "__main__":
74 74
Menu

Theme