32.3 CI: kódellenőrzés és kis számítás új környezetben

32.3.1 Helyzet, bemenet és előzetes becslés

A data scientist a Northstar labor tisztított adatelőállítását módosította. A helyi gépén a parancsok zöldek, de a távoli gépes munkamenetben, amelyet a Távoli interpreter és notebook biztonságos elérése alfejezetben állított be, más operációs rendszer és más Python-telepítés fut. A kérdés: minden commit után friss, tiszta környezetben is teljesül-e a kis adatra vonatkozó szerződés, mielőtt bárki a drága modellkísérletre költ?

A CI-ellenőrzés bemenete a repository aktuális commitja, benne a lab/generate_data.py, a lab/quickcheck.py, a szállított tiszta CSV-k és a manifest. Megfigyelési egység egy teljes CI-futás; a reportban a rendelés az összegzési sor, a tétel a join-kardinalitás sor, az ügyfél pedig a randomizációs egység marad. A cél olyan kontrollaggregáció, amely hibás adatra nemnulla exit-kóddal és ember által olvasható reporttal áll le.

Előzetes becslés: az alap seeddel generált tiszta adaton 2400 rendelés, 4796 tétel és 300 ügyfél várható. Minden rendelésre a tételek line_total_minor összegének pontosan basket_minor-ral kell egyeznie. Egész minor unitban dolgozunk, ezért itt nincs lebegőpontos tűrés: az egyenlőség ==-tel ellenőrzendő.

32.3.2 Mit jelent a CI, és mit nem bizonyít a zöld állapot?

A folyamatos integráció (continuous integration, CI) egy gépi szerződés: valamilyen eseményre — push, pull request, kézi indítás — friss környezetben lefuttat egy parancssort, és a teljes folyamat visszatérési kódját zöldre vagy pirosra minősíti. A CI job egy futtatási egység, a workflow több job összekötése, a runner pedig a Linux-, Windows- vagy macOS-gép, amely a jobot végrehajtja.

A zöld állapot mindig feltételes: „ez a workflow, ezen a runner-képpel, ezekkel a rögzített lépésekkel, ezzel az inputtal nem talált hibát”. Nem jelenti azt, hogy a modell minden támogatott platformon bitre azonosan reprodukálódik, hogy a statisztikai következtetés helyes, vagy hogy az üzleti cél teljesül. A Generalizáció és gyorsító választás alfejezetben látott seed-korlát itt visszaköszön: a seed ismételhetőséget adhat azonos környezetben, de nem garantál bitazonosságot eltérő könyvtár-, BLAS- vagy processz-szálazási környezetben.

A zöld CI ezért szűk, pontos mondatokat támaszt alá: például „a CSV-séma beolvasható”, „a kulcsok egyediek”, „a tételszintű pénzösszeg megegyezik a rendelésszintű kosárösszeggel”.

32.3.3 Numerikus tolerancia és determinisztikus kontroll

A labor pénzadata egész minor unit, így az ellenőrzés egzakt. Amikor a jövőben lebegőpontos metrikát hasonlítunk referenciához, abszolút és relatív tűrést együtt kell használni:

|x̂−xref|≤atol+rtol⋅|xref|. \left| \hat{x} - x_{\mathrm{ref}} \right| \le \mathrm{atol} + \mathrm{rtol} \cdot \left| x_{\mathrm{ref}} \right|.

Itt x̂\hat{x} a mért érték, xrefx_{\mathrm{ref}} a referencia, atol\mathrm{atol} az abszolút tűrés, rtol\mathrm{rtol} a relatív tűrés; mindkettő dimenziótlan vagy a mért mennyiség mértékegységében értendő. Ha atol = 1e-9 és rtol = 1e-6, akkor a 0.1 + 0.2 Pythonbeli értékének 0.3-től való, nagyjából 5.55e-17 eltérése elfogadható, mert a felső határ 1e-9 + 1e-6 * 0.3 ≈ 3.01e-7.

A determinisztikus inputokra célszerű byte-szintű lenyomatot is rögzíteni. Az SHA-256 hash az adott fájl bájtjainak egyirányú, determinisztikus lenyomata. Ha két fájl bájtszinten azonos, a hash azonos; ha a sortörés átalakult CRLF-re, a bájtok mások, így a hash is más lesz. Ezért a hash a CI-ben jó kontroll, de nem mondja meg, hogy a különbség tartalmi hiba vagy csak platformos sortörés.

32.3.4 Teljes minimális példa: Northstar CI-specifikáció

Az alábbi YAML szemléltető specifikáció, nem kipróbált futási jegyzék. A pontos parancsargumentumokat a labor projekt README-jéhez kell igazítani; a lépések szerkezete azonban átvihető bármely kis adatos repositoryba.

# .github/workflows/northstar-ci.yml
name: northstar-ci
on:
  push:
    paths:
      - "lab/**"
      - "ci/**"
      - ".github/workflows/northstar-ci.yml"
  pull_request:

jobs:
  data-contract:
    runs-on: ubuntu-24.04
    timeout-minutes: 10
    steps:
      - name: Repository kód kiadása
        uses: actions/checkout@v4 # éles használatban commit-SHA pin ajánlott
      - name: Python 3.12
        uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - name: Deterministic data generation
        run: python lab/generate_data.py
      - name: Contract check
        run: python lab/quickcheck.py
      - name: Aggregation guard
        run: >-
          python ci/aggregate_check.py
          --orders lab/orders.csv
          --items lab/order_items.csv
          --customers lab/customers.csv
          --out artifacts/ci/report.json
      - name: Report artifact
        uses: actions/upload-artifact@v4
        with:
          name: aggregation-report
          path: artifacts/ci/report.json
          retention-days: 7

  dependency-lock:
    runs-on: ubuntu-24.04
    timeout-minutes: 8
    steps:
      - uses: actions/checkout@v4
      - name: Cache uv cache
        uses: actions/cache@v4
        with:
          path: ~/.cache/uv
          key: uv-${{ runner.os }}-${{ hashFiles('lab/pyproject.toml', 'lab/uv.lock') }}
          restore-keys: |
            uv-${{ runner.os }}-
      - name: Install uv from project-pinned version
        run: python -m pip install uv
      - name: Environment sync from committed lock
        working-directory: lab
        run: uv sync --locked

A data-contract job csak a standard libraryt igénylő generátort, kontrollt és aggregációt futtatja. A dependency-lock job azt ellenőrzi, hogy a uv.lock a repositoryban van-e, és a --locked kapcsoló miatt akkor is pirosra vált, ha valaki kézzel megváltoztatta a pyproject.toml-t anélkül, hogy a lockot is frissítette volna. Ez szándékos: a CI nem oldhat fel csendben új verziót, mert akkor a zöld állapot egy másik függőségi környezetet igazolna.

A cache csak gyorsító, nem ellenőrzés. A kulcs a pyproject.toml és a uv.lock hashéből képződik, így változáskor új cache-ág épül. A --frozen és a --locked közötti különbségről az uv projekt és ténylegesen rögzített függőségek alfejezetben tanultak érvényesek: a CI-ben a --locked a megfelelő, mert azt is ellenőrzi, hogy a lock nincs-e elavulva.

A kontrollaggregációhoz használt ci/aggregate_check.py standard library-only változata:

#!/usr/bin/env python3
"""Northstar determinisztikus aggregáció-ellenőrzés; stdlib only."""
import argparse
import csv
import hashlib
import json
import sys
from pathlib import Path

EXPECTED = {"customers": 300, "orders": 2400, "items": 4796}

def sha256(path: Path) -> str:
    return hashlib.sha256(path.read_bytes()).hexdigest()

def main() -> int:
    p = argparse.ArgumentParser()
    p.add_argument("--orders", type=Path, default=Path("lab/orders.csv"))
    p.add_argument("--items", type=Path, default=Path("lab/order_items.csv"))
    p.add_argument("--customers", type=Path, default=Path("lab/customers.csv"))
    p.add_argument("--out", type=Path, default=Path("artifacts/ci/report.json"))
    args = p.parse_args()

    order_ids, baskets, order_customer_ids, duplicate_orders = set(), {}, set(), 0
    with args.orders.open(newline="", encoding="utf-8") as f:
        for row in csv.DictReader(f):
            oid = row["order_id"]
            if oid in order_ids:
                duplicate_orders += 1
            order_ids.add(oid)
            baskets[oid] = int(row["basket_minor"])
            order_customer_ids.add(row["customer_id"])

    item_spend, item_keys, unknown_refs, duplicate_item_keys = {}, set(), 0, 0
    with args.items.open(newline="", encoding="utf-8") as f:
        for row in csv.DictReader(f):
            key = (row["order_id"], row["line_id"])
            if key in item_keys:
                duplicate_item_keys += 1
            item_keys.add(key)
            if row["order_id"] not in baskets:
                unknown_refs += 1
            line_total = (
                int(row["quantity"]) * int(row["unit_price_minor"])
                - int(row["discount_minor"])
            )
            item_spend[row["order_id"]] = (
                item_spend.get(row["order_id"], 0) + line_total
            )

    customer_ids, customer_rows = set(), 0
    with args.customers.open(newline="", encoding="utf-8") as f:
        for row in csv.DictReader(f):
            customer_rows += 1
            customer_ids.add(row["customer_id"])

    mismatches = [
        {"order_id": oid, "expected_minor": expected,
         "actual_minor": item_spend.get(oid)}
        for oid, expected in baskets.items()
        if item_spend.get(oid) != expected
    ]

    report = {
        "expect_ref": EXPECTED,
        "actual": {
            "orders": len(order_ids),
            "items": len(item_keys),
            "customers": customer_rows,
        },
        "money_match": {
            "orders_with_items": len([o for o in baskets if o in item_spend]),
            "mismatch_count": len(mismatches),
            "mismatch_examples": mismatches[:3],
        },
        "key_checks": {
            "duplicate_order_ids": duplicate_orders,
            "duplicate_item_keys": duplicate_item_keys,
            "unknown_order_refs": unknown_refs,
            "missing_customer_examples": sorted(order_customer_ids - customer_ids)[:5],
        },
        "input_sha256": {
            "orders": sha256(args.orders),
            "items": sha256(args.items),
            "customers": sha256(args.customers),
        },
    }

    ok = (
        len(order_ids) == EXPECTED["orders"]
        and len(item_keys) == EXPECTED["items"]
        and customer_rows == EXPECTED["customers"]
        and duplicate_orders == 0
        and duplicate_item_keys == 0
        and unknown_refs == 0
        and not (order_customer_ids - customer_ids)
        and len(mismatches) == 0
    )

    args.out.parent.mkdir(parents=True, exist_ok=True)
    args.out.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n",
                        encoding="utf-8")
    print(json.dumps(report, ensure_ascii=False, indent=2))
    return 0 if ok else 1

if __name__ == "__main__":
    sys.exit(main())

A szkript shape-ellenőrzései egyben adatminőségi kapuk: a várt sor- és kulcsszám, a kulcs-egyediség, az idegen kulcsok létezése és a pénzösszeg-egyezés. Az artifacts/ci/report.json csak a reportot és a hasheket tartalmazza, sem ügyfél-szintű nyers sort, sem titkot.

32.3.5 Kézi kis ellenőrzés a kardinalitási csapdára

A példa ellenőrzéséhez egy háromsoros mesterséges bemenetet érdemes kézzel számolni:

order_id line_id quantity unit_price_minor discount_minor line_total_minor
O1 L1 2 100 0 200
O1 L2 3 50 0 150

Ha a rendelés basket_minor = 350, és shipping_fee_minor = 90, akkor a tételszintű összeg 200 + 150 = 350, tehát a kosár-invariant teljesül. Ha viszont valaki a szállítási díjat tételszintű join után összegzi, az O1 kétszer szerepel a joinban, és 180-at kap 90 helyett. A Join-kardinalitás és duplázódó célváltozó alfejezetben tanult one-to-many csapda így a CI-ban külön diagnosztikai értékként is megjelenhet; a helyes shipping_totals-t csak a rendelés-szemcsézettségű táblából szabad összegezni.

32.3.6 Drága tanítás külön lépésben

A Northstar starter_experiment.py prediktív futása és az Egyszerű előrejelző modellek és baseline idősor-baselineok kiértékelése nem minden commitra való. A CI-specifikációban a drága lépés legyen kézi indítású job:

  training:
    runs-on: ubuntu-24.04
    timeout-minutes: 45
    if: github.event_name == 'workflow_dispatch'
    needs: [data-contract, dependency-lock]
    steps:
      - uses: actions/checkout@v4
      - name: Full baseline experiment
        working-directory: lab
        run: uv run --locked python starter_experiment.py
      - name: Keep only small experiment artifacts
        uses: actions/upload-artifact@v4
        with:
          name: experiment-artifact
          path: artifacts/
          retention-days: 5

Itt nincs valódi felhős erőforrásigény: a kiinduló labor CPU-s, kis adatos. A workflow_dispatch miatt a drága tanítás csak akkor fut, amikor a csapat kéri. Az artifact retention rövid, és nem a teljes modellt, hanem a futási manifestet, split- és metrikafájlokat célozza. A Modellartefaktum és betöltési határ alfejezet betöltési határa érvényben marad: a CI által előállított artifact visszatöltésénél verzió- és sémavizsgálat kell.

32.3.7 Feladatok

  1. Értelmezés. A CI data-contract jobja zöld. Írja le, mely állításokat támasztja alá ez a zöld állapot, és melyeket nem. Nevezzen meg legalább két olyan következtetést, amelyet tilos ebből levonni.

  2. Implementálás. Bővítse a ci/aggregate_check.py-t egy shipping_duplication_guard riportmezővel. A kód számítsa ki egyszer a rendelésenkénti shipping_fee_minor összeget, majd azt a hipotetikus összeget, amelyet az order_items tételszámával megszorzott szállítási díjak adnának. Mindkét értéket írja a report-ba shipping_guard kulcs alatt. A repo a Northstar tiszta CSV-it használja; nem kell valódi felhőt hívni.

  3. Hibadiagnózis. A dependency-lock job a uv sync --locked lépésnél piros lesz: „lockfile does not exist”. A fejlesztő erre a --frozen-re cserélné a parancsot, hogy a CI zöld legyen. Mondja meg, miért rossz ez a diagnózis, és mi lenne a helyes javítás.

  4. Megváltozott feltétel. A csapat úgy dönt, hogy a prediktív baseline-t hetente egyszer futtatja a CI-ben, de továbbra sem minden commitra. Írja át a workflow-t úgy, hogy a training job ütemezett cron ütemben is induljon, és a data-contract job továbbra is minden pushra fusson. Az artifact megőrzési idő maradjon 5 nap.

32.3.8 Megoldások és indoklás

1. A zöld data-contract job azt igazolja, hogy az adott runner-képen, az adott commitban a generátor lefutott, a quickcheck szerződése teljesült, a CSV-k beolvashatók voltak, a várt sor- és kulcsszámok egyeztek, és a tételszintű pénzösszeg minden beolvasott rendelésre megegyezett a basket_minor értékkel. Nem igazolja, hogy a modell jövőbeli predikciója pontos, hogy a kísérleti ITT-hatás konfidenciaintervalluma minden új mintán fed, vagy hogy a workflow egy másik platformon is bitre azonos modellt hozna létre. A zöld állapot csak a definiált adatszerződést ellenőrizte.

2. A reportba illeszthető kontroll minimális kiegészítése:

correct_shipping = 0
hypothetical_shipping = 0
with args.orders.open(newline="", encoding="utf-8") as f:
    for row in csv.DictReader(f):
        count = 0
        for item_row in item_count_by_order.get(row["order_id"], []):
            count = item_count_by_order[row["order_id"]]
        correct_shipping += int(row["shipping_fee_minor"])
        hypothetical_shipping += count * int(row["shipping_fee_minor"])

A megoldásnak pontos adatszerkezetet kell adnia: item_count_by_order[oid] az order_id-hoz tartozó line_id-k száma. Az elvárt invariant hypothetical_shipping >= correct_shipping, és a különbség sum((item_count - 1) * shipping_fee_minor). A kontroll nem hibát dob feltétlenül, hanem a reportban láthatóvá teszi a kardinalitási csapda lehetséges mértékét. Ellenőrzése a belinkelt tiszta CSV-k hashelt bemenetén, megegyező műveleti sorrend mellett reprodukálható; lebegőpontos tűrésre itt nincs szükség, mert a pénz egész cent.

3. A --frozen kihagyja a lock aktualitásvizsgálatát, tehát a CI egy olyan lockot fogadna el, amely nem tükrözi a pyproject.toml-t. Ezzel nem oldódik meg a hiány vagy az elavulás, csak elrejtjük a piros jelzést. A helyes javítás: a fejlesztő a lokális projektben futtatja az uv lock parancsot, a keletkezett uv.lock-ot commitolja, majd a CI-t újra futtatja. A cache jelenléte itt irreleváns, mert a cache tárolt csomagokat, nem lockállományt.

4. A training job indítási feltétele legyen:

on:
  push:
    paths: [...]
  pull_request:
  schedule:
    - cron: "30 4 * * 1"
  workflow_dispatch:

A training jobban a feltétel:

if: github.event_name == 'workflow_dispatch' ||
    github.event_name == 'schedule'

A data-contract job marad push-onként. A pontos cron kifejezést a repository UTC-szabályaihoz kell igazítani; az itteni 30 4 * * 1 hetfőn 04:30 UTC-t jelöl. A cron ütemezés a platform szolgáltatása, ezért a pontos időzítés a szolgáltató fiókbeállításaitól függhet.

32.3.9 Gyakori tévedés és korlát

Tévedés: „A CI zöld lett, tehát a modell a célplatformon is reprodukálódik.” Ellenpélda: a CI Linux-ubuntu runneren futtatott egy szkicit-learn pipeline-t, és az artifactot egy Windows-os gép tölti vissza. A modell float-számításai a NumPy verziójától, a BLAS-tól és a szálak összegzési sorrendjétől függően kicsit eltérhetnek. A CI nem futtatta a tényleges célplatformot, így a bitazonosságot nem is mérte. Hasonló korlátot mutatott a Generalizáció és gyorsító választás alfejezet a seed-ek platformfüggőségénél és a Modellartefaktum és betöltési határ az artifact-visszatöltésnél: a zöld pipeline nem menti fel a csapatot a betöltéskor futtatott séma- és predikciós szerződésvizsgálat alól.

32.3.10 Önellenőrzés

A CI zöld státusza nem bizonyítja, hogy a modell statisztikailag megfelelő; a tesztek csak a kifejezetten megfogalmazott szerződéseket ellenőrzik.

32.3.11 Rövid összefoglaló

A CI a repository minden pushját friss, tiszta Linux-környezetben futtatott kis adatos szerződésként ellenőrzi. A Northstar-példában a generátor, a quickcheck és a kontrollaggregáció egy gyors standard library-only jobban fut: kulcsegyediséget, várható sor- és tételszámot, valamint tételszintű pénzösszeg-egyezést ellenőriz. A függőségek szinkronizálása uv sync --locked módon történik, a cache csak gyorsít. A drága modelltanítás kézi vagy ütemezett külön job, rövid artifact retentionnel. A zöld CI szűk, feltételes állítás, nem teljes platformos modellreprodukció. A következő lépésben a VIII. ellenőrző pont ezekből az elemekből állít össze átadható modellprojektet, beleértve a betöltési határokat és a futási manifestet.