SFM/tests/unit/test_mapping_index.py
Andrey Epifancev 6668858b6f Pick report column values through a reverse path index
build_fixed_row_by_index evaluated ~2 400 candidate paths per report row
(7 million _pick_value calls per 13 800 rows). A reverse index
"path -> (rule, candidate, probe)" is built once at import; per row we
walk only the record's own fields and keep the best hit per rule, with
the same candidate/probe ordering as _pick_value. Differential test
compares the index with _pick_value on random and parsed payloads.

Also: merged lookup reuses the operation lookup when the participant
payload is empty, and index-free paths skip the regex normalisation.
scripts/bench_report.py reproduces the measurement (0.42 -> 0.26 ms/row).

Refs #1

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-09 19:14:43 +04:00

188 lines
9.6 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Индексная выборка значений колонок должна давать то же, что _pick_value.
_pick_value / _pick_structured_value — эталонная (медленная) семантика:
на каждую строку отчёта перебираются все кандидаты каждого правила. Быстрый
путь строит обратный индекс «путь -> правила» один раз и за строку проходит
только по полям самой записи. Тест сверяет оба пути на случайных и реальных
payload по всем правилам и всем источникам (operation / participant / merged).
"""
from __future__ import annotations
import random
from app.pipeline import mapping
from app.pipeline.column_constants import BLOCK_EIO
from app.pipeline.mapping import (
REPORT_MAPPING_RULES,
_build_payload_lookup,
_merge_fields,
_partition_participant_lookup,
_pick_structured_value,
_pick_value,
_rule_lookup_source,
pick_rule_values_indexed,
)
from app.pipeline.parser import parse_xml_content
_PREFIXES = ("", "УчастникОп.", "СообщОперКО.ИнформЧасть.СведКО.Операция.")
def _candidate_pool() -> list[str]:
pool: set[str] = set()
for rule in REPORT_MAPPING_RULES:
for candidate in rule.compiled_candidates:
pool.add(candidate.raw)
pool.add(candidate.short)
parts = candidate.raw.split(".")
for start in range(len(parts)):
pool.add(".".join(parts[start:]))
return sorted(pool)
def _random_payload(rng: random.Random, pool: list[str]) -> dict[str, str]:
payload: dict[str, str] = {}
for _ in range(rng.randint(1, 40)):
key = rng.choice(pool)
prefix = rng.choice(_PREFIXES)
if rng.random() < 0.25:
parts = key.split(".")
position = rng.randrange(len(parts))
parts[position] = f"{parts[position]}[{rng.randint(0, 2)}]"
key = ".".join(parts)
key = f"{prefix}{key}"
value = "" if rng.random() < 0.1 else f"v{rng.randint(1, 5)}"
payload[key] = value
if rng.random() < 0.3:
payload[f"Шум{rng.randint(1, 9)}.Поле"] = "шум"
return payload
def _reference_values(lookup, source: str) -> dict[int, str]:
expected: dict[int, str] = {}
for position, rule in enumerate(REPORT_MAPPING_RULES):
if not rule.allow_direct_mapping or _rule_lookup_source(rule) != source:
continue
allow_short_lookup = rule.allow_short_lookup or rule.column_index in BLOCK_EIO
if rule.structured_value:
expected[position] = _pick_structured_value(
lookup=lookup,
candidates=rule.compiled_candidates,
join_with_space="фио" in rule.column_name.lower(),
allow_short_lookup=allow_short_lookup,
)
else:
expected[position] = _pick_value(
lookup,
rule.compiled_candidates,
allow_short_lookup=allow_short_lookup,
)
return expected
def _lookups_for(operation_fields, participant_fields):
operation_lookup = _build_payload_lookup(operation_fields)
participant_lookup = _build_payload_lookup(participant_fields)
base_lookup, eio_lookup = _partition_participant_lookup(participant_lookup)
merged_lookup = _build_payload_lookup(
_merge_fields(operation_fields, participant_fields)
)
return {
"operation": operation_lookup,
"participant_base": base_lookup,
"participant_eio": eio_lookup,
"merged": merged_lookup,
}
def _assert_same_values(operation_fields, participant_fields, label: str) -> None:
for source, lookup in _lookups_for(operation_fields, participant_fields).items():
expected = _reference_values(lookup, source)
actual = pick_rule_values_indexed(lookup, source)
for position, expected_value in expected.items():
rule = REPORT_MAPPING_RULES[position]
assert actual.get(position, "") == expected_value, (
f"{label}: source={source}, колонка {rule.column_index} "
f"({rule.column_id}): ожидалось {expected_value!r}, "
f"получено {actual.get(position, '')!r}"
)
extra = set(actual) - set(expected)
assert not extra, f"{label}: лишние правила в индексной выборке: {extra}"
def test_indexed_values_match_pick_value_on_random_payloads() -> None:
rng = random.Random(20261009)
pool = _candidate_pool()
for sample in range(400):
operation_fields = _random_payload(rng, pool)
participant_fields = _random_payload(rng, pool)
_assert_same_values(operation_fields, participant_fields, f"sample {sample}")
def test_indexed_values_match_pick_value_on_parsed_xml() -> None:
xml = """<?xml version="1.0" encoding="UTF-8"?>
<СообщОперКО xmlns="urn:test">
<СлужЧасть><ИдФайл>ID1</ИдФайл><ДатаФайла>2026-06-16</ДатаФайла></СлужЧасть>
<ИнформЧасть>
<ИнфБанк><БИК>044525111</БИК><НаимКО>АО Банк</НаимКО></ИнфБанк>
<СведКО>
<ИнфФилиал><НомерФилиала>01</НомерФилиала></ИнфФилиал>
<Операция>
<ИдентификаторЗаписи>OP1</ИдентификаторЗаписи>
<КодОперации>6001</КодОперации>
<ДатаОперации>2026-06-16</ДатаОперации>
<СуммаОперации>12345.67</СуммаОперации>
<ВалютаОперации>643</ВалютаОперации>
<НазначениеПлатежа>Оплата по договору</НазначениеПлатежа>
<ОснованиеОп><КодДок>21</КодДок><НомДок>1</НомДок><ДатаДок>2026-06-01</ДатаДок></ОснованиеОп>
<ОснованиеОп><КодДок>22</КодДок><НомДок>2</НомДок><ДатаДок>2026-06-02</ДатаДок></ОснованиеОп>
<СведПереводДС>
<ВидПеревода>1</ВидПеревода>
<СведБанкПлательщик><БИККО>044525111</БИККО><НаимБанк>АО Банк</НаимБанк><НомерСчета>40702810000000000001</НомерСчета></СведБанкПлательщик>
<СведБанкПолучатель><БИККО>044525222</БИККО><НаимБанк>ПАО Банк2</НаимБанк><НомерСчета>40702810000000000002</НомерСчета></СведБанкПолучатель>
</СведПереводДС>
<УчастникОп>
<ТипУчастника>2</ТипУчастника>
<УчастникФЛИП>
<СведФЛИП>
<ФИО><Фамилия>Иванов</Фамилия><Имя>Иван</Имя><Отчество>Иванович</Отчество></ФИО>
<ИННФЛИП>123456789012</ИННФЛИП>
<ДатаРождения>1980-01-01</ДатаРождения>
<КодОКСМ>643</КодОКСМ>
<СведДокУдЛичн><КодДок>21</КодДок><СерияДок>4510</СерияДок><НомДок>123456</НомДок><ДатаДок>2010-01-01</ДатаДок><КемВыдан>ОУФМС</КемВыдан></СведДокУдЛичн>
<АдрРег Индекс="674500" КодОКСМ="643" КодСубъектаПоОКАТО="45" Пункт="Москва г" Улица="Ленина ул" Дом="1" Корпус="2" Квартира="3"/>
</СведФЛИП>
</УчастникФЛИП>
</УчастникОп>
<УчастникОп>
<ТипУчастника>1</ТипУчастника>
<УчастникЮЛ>
<СведЮЛ>
<НаимЮЛ>ООО Ромашка</НаимЮЛ><ИННЮЛ>7701234567</ИННЮЛ><КППЮЛ>770101001</КППЮЛ><ОГРН>1027700000001</ОГРН>
<АдрРегЮЛ Индекс="675520" КодОКСМ="643" КодСубъектаПоОКАТО="10" Район="Благовещенский р-н" Пункт="Чигири с" Улица="Зеленая ул" Дом="1"/>
</СведЮЛ>
<СведЕИО><ФЛЕИО><ФИО><Фамилия>Петров</Фамилия><Имя>Петр</Имя></ФИО><ИННФЛИП>111111111111</ИННФЛИП></ФЛЕИО></СведЕИО>
<БенефициарЮЛ><ФЛЕИО><ФИО><Фамилия>Сидоров</Фамилия><Имя>Сидор</Имя></ФИО><ИННФЛИП>222222222222</ИННФЛИП></ФЛЕИО></БенефициарЮЛ>
</УчастникЮЛ>
</УчастникОп>
</Операция>
</СведКО>
</ИнформЧасть>
</СообщОперКО>
"""
result = parse_xml_content("SKO115FZ_01_044525111_20260616_000001.xml", xml)
assert not result.is_fatal
assert result.rows
for row in result.rows:
_assert_same_values(
row.operation_fields,
row.participant_fields,
f"row {row.operation_index}/{row.is_continuation}",
)
def test_every_direct_rule_has_exactly_one_lookup_source() -> None:
for rule in REPORT_MAPPING_RULES:
source = _rule_lookup_source(rule)
assert source in mapping.LOOKUP_SOURCES, (rule.column_id, source)