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>
188 lines
9.6 KiB
Python
188 lines
9.6 KiB
Python
"""Индексная выборка значений колонок должна давать то же, что _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)
|