# MatchSpec

Single source of truth for SEO Rule matching in PHP and JS.

## Goal

Both runtimes must make the same decision:
- whether a rule matches the current request,
- which rule wins when multiple rules match.

No matching decisions should be implemented outside this spec.

## Input Contract

- `context_base_view`: string (e.g. `shop:main`, `product_cat:123`)
- `request_filters`: map `slug => [selected values]`
- `rules`: list of SEO rules (each has `base_view`, `rules`, `indexable`, `id`)
- `is_range_filter(slug)`: tells if a slug is a range filter

Normalization expectations:
- request filter keys are unique
- request values are normalized slugs
- search filter is excluded before matching

## Rule Match Conditions

A rule matches only if all conditions are true:

1. `indexable === true`
2. `base_view` matches:
   - exact match (`product_cat:123` vs `product_cat:123`)
   - wildcard match (`product_cat:any` vs any `product_cat:*` with non-empty arg)
3. Exact filter-set match:
   - rule filter keys must equal request filter keys (same set, no extras on either side)
4. For each filter slug:
   - request must have exactly one selected value
   - if rule value list is empty (`[]`), it means “any single value” for that slug
   - if rule has values, selected value must match
   - for range filters, range comparison is used first; when input is not a valid range token, fallback is exact value match

## Range Matching

Range token format: `"min-max"` where each side may be empty.

Examples:
- `"10-50"` (both bounds)
- `"10-"` (min only)
- `"-50"` (max only)

Range rule matching:
- request both bounds -> rule must define both bounds and they must be equal
- request min only -> rule must define only min and it must be equal
- request max only -> rule must define only max and it must be equal

## Winner Selection (Deterministic Specificity)

All matching rules are ranked lexicographically by:

1. `base_view_specificity` (desc): exact > `:any`
2. `matched_filters` (desc)
3. `constrained_filters` (desc): filters with non-empty rule values
4. `specificity_signature` (lexicographic):
   - range: both bounds > single bound > fallback
   - for same level: fewer allowed values wins (e.g. `["red"]` > `["red","blue"]`)
5. `deterministic_key` (asc):
   - stable rule id when available
   - otherwise canonical hash of base view + normalized rules

This removes dependence on iteration order.

## Business Examples

### Example 1: exact base view vs wildcard

- Rule A: `product_cat:any`, `color=[red]`
- Rule B: `product_cat:123`, `color=[red]`
- Request: `product_cat:123`, `color=red`

Winner: Rule B (exact base view is more specific).

### Example 2: narrow value vs broad value set

- Rule A: `color=[red,blue], size=[m]`
- Rule B: `color=[red], size=[m]`
- Request: `color=red, size=m`

Winner: Rule B (fewer allowed values = more specific).

### Example 3: exact filter-set required (extra filter in request)

- Rule A: `base_view=product_cat:123`, `rules={ color=[red] }`
- Request: `product_cat:123`, `color=red`, `size=m`

Winner: no rule (request has extra filter not present in the rule).

### Example 4: exact filter-set required (missing filter in request)

- Rule A: `base_view=product_cat:123`, `rules={ color=[red], size=[m] }`
- Request: `product_cat:123`, `color=red`

Winner: no rule (request is missing `size`).

### Example 5: empty rule values mean “any single selected value”

- Rule A: `base_view=product_cat:123`, `rules={ color=[] }`
- Request: `product_cat:123`, `color=blue`

Winner: Rule A (empty list accepts any single value for that filter).

### Example 6: constrained filter beats unconstrained filter

- Rule A: `base_view=product_cat:123`, `rules={ color=[] }`
- Rule B: `base_view=product_cat:123`, `rules={ color=[red] }`
- Request: `product_cat:123`, `color=red`

Winner: Rule B (constrained value is more specific than `[]`).

### Example 7: non-indexable rule is ignored

- Rule A: `base_view=product_cat:123`, `rules={ color=[red] }`, `indexable=false`
- Rule B: `base_view=product_cat:any`, `rules={ color=[red] }`, `indexable=true`
- Request: `product_cat:123`, `color=red`

Winner: Rule B (Rule A is not eligible).

### Example 8: range filter, exact full range beats open range

- Rule A: `base_view=product_cat:123`, `rules={ price=[10,50] }`
- Rule B: `base_view=product_cat:123`, `rules={ price=[10,''] }`
- Request: `product_cat:123`, `price=10-50`

Winner: Rule A (exact full range is more specific and exact-match for request).

### Example 9: range filter, min-only request

- Rule A: `base_view=product_cat:123`, `rules={ price=[10,''] }`
- Rule B: `base_view=product_cat:123`, `rules={ price=[10,50] }`
- Request: `product_cat:123`, `price=10-`

Winner: Rule A (min-only request matches min-only rule, not full range).

### Example 10: range filter, max-only request

- Rule A: `base_view=product_cat:123`, `rules={ price=['',50] }`
- Rule B: `base_view=product_cat:123`, `rules={ price=[10,50] }`
- Request: `product_cat:123`, `price=-50`

Winner: Rule A (max-only request matches max-only rule, not full range).

### Example 11: deterministic tie-breaker by rule id

- Rule A: `id=10`, `base_view=product_cat:123`, `rules={ color=[red] }`
- Rule B: `id=25`, `base_view=product_cat:123`, `rules={ color=[red] }`
- Request: `product_cat:123`, `color=red`

Winner: Rule A (all ranking dimensions equal, lower stable id wins).

### Example 12: base view type mismatch

- Rule A: `base_view=shop:main`, `rules={ color=[red] }`
- Request: `product_cat:123`, `color=red`

Winner: no rule (base view type does not match).

## Business Examples (Quick Table)

| # | Rule A | Rule B | Request | Winner | Why |
|---|---|---|---|---|---|
| 1 | `base_view=product_cat:any`, `color=[red]` | `base_view=product_cat:123`, `color=[red]` | `product_cat:123`, `color=red` | Rule B | Exact `base_view` is more specific than `:any`. |
| 2 | `color=[red,blue]`, `size=[m]` | `color=[red]`, `size=[m]` | `color=red`, `size=m` | Rule B | Fewer allowed values is more specific. |
| 3 | `base_view=product_cat:123`, `rules={ color=[red] }` | — | `product_cat:123`, `color=red`, `size=m` | No rule | Exact filter-set mismatch (extra filter in request). |
| 4 | `base_view=product_cat:123`, `rules={ color=[red], size=[m] }` | — | `product_cat:123`, `color=red` | No rule | Exact filter-set mismatch (missing filter in request). |
| 5 | `base_view=product_cat:123`, `rules={ color=[] }` | — | `product_cat:123`, `color=blue` | Rule A | Empty list means “any single selected value”. |
| 6 | `base_view=product_cat:123`, `rules={ color=[] }` | `base_view=product_cat:123`, `rules={ color=[red] }` | `product_cat:123`, `color=red` | Rule B | Constrained value is more specific than empty list. |
| 7 | `base_view=product_cat:123`, `rules={ color=[red] }`, `indexable=false` | `base_view=product_cat:any`, `rules={ color=[red] }`, `indexable=true` | `product_cat:123`, `color=red` | Rule B | Non-indexable rules are ignored. |
| 8 | `base_view=product_cat:123`, `rules={ price=[10,50] }` | `base_view=product_cat:123`, `rules={ price=[10,''] }` | `product_cat:123`, `price=10-50` | Rule A | Full-range exact match beats open range. |
| 9 | `base_view=product_cat:123`, `rules={ price=[10,''] }` | `base_view=product_cat:123`, `rules={ price=[10,50] }` | `product_cat:123`, `price=10-` | Rule A | Min-only request matches min-only rule. |
| 10 | `base_view=product_cat:123`, `rules={ price=['',50] }` | `base_view=product_cat:123`, `rules={ price=[10,50] }` | `product_cat:123`, `price=-50` | Rule A | Max-only request matches max-only rule. |
| 11 | `id=10`, `base_view=product_cat:123`, `rules={ color=[red] }` | `id=25`, `base_view=product_cat:123`, `rules={ color=[red] }` | `product_cat:123`, `color=red` | Rule A | Deterministic tie-breaker: lower stable id wins. |
| 12 | `base_view=shop:main`, `rules={ color=[red] }` | — | `product_cat:123`, `color=red` | No rule | Base view type mismatch. |
