File size: 5,337 Bytes
4378390
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
/**
 * DZ Phone Value — ONNX inference engine (static HuggingFace Space).
 *
 * Feature engineering + post-processing are ported 1:1 from the production
 * engine sp_repo/src/lib/model/predict.ts (sp10-a2-refined), which is pinned
 * by two gates: model_export/sp_parity_check.mjs (12 Python-exported fixtures,
 * 0.1%) and scripts/check-golden.ts (byte-exact API goldens). The tree
 * ensembles run through onnxruntime-web from model.onnx (point / q10 / q90
 * log-price margins, base_values baked — ORT output equals the TS engine's
 * pLog exactly; see scripts/sp_export_onnx.py for the conversion contract).
 */
(function (global) {
  "use strict";

  let ART = null;

  function init(artifacts) { ART = artifacts; }

  function getArtifacts() { return ART; }

  function modelAgeYears(releaseYear) {
    var launch = new Date(releaseYear, 5, 30).getTime(); // ~mid-year launch assumption
    var age = (Date.now() - launch) / (365.25 * 24 * 3600 * 1000);
    return Math.max(0, Math.min(14, age));
  }

  /** Mirror of predict.ts buildFeatures() — same order, same truthiness. */
  function buildFeatures(inp) {
    var a = ART;
    var m = a.medians;
    var f = {};

    // numeric block
    f.log2_storage = inp.storageGb ? Math.log2(inp.storageGb) : m.log2_storage;
    f.storage_missing = inp.storageGb ? 0 : 1;
    f.ram_gb = (inp.ramGb === null || inp.ramGb === undefined) ? m.ram_gb : inp.ramGb;
    f.ram_missing = inp.ramGb ? 0 : 1;
    f.model_age_years = inp.releaseYear ? modelAgeYears(inp.releaseYear) : m.model_age_years;
    f.age_missing = inp.releaseYear ? 0 : 1;
    f.battery_health = (inp.batteryHealth === null || inp.batteryHealth === undefined) ? m.battery_health : inp.batteryHealth;
    f.battery_missing = inp.batteryHealth ? 0 : 1;
    f.desc_damage = inp.damage ? 1 : 0;
    f.desc_original_parts = inp.originalParts ? 1 : 0;
    f.is_5g = inp.is5g ? 1 : 0;
    f.dual_sim = inp.dualSim ? 1 : 0;
    f.acc_count = (inp.accessories.box ? 1 : 0) + (inp.accessories.charger ? 1 : 0) + (inp.accessories.case ? 1 : 0);
    f.log_repost = Math.log1p(1); // fresh listing

    // tier dummies (reference: tier_none)
    var tiers = ["base", "compact", "plus", "pro", "promax", "ultra"];
    for (var i = 0; i < tiers.length; i++) f["tier_" + tiers[i]] = inp.tier === tiers[i] ? 1 : 0;
    // brand dummies (reference: other)
    for (var b = 0; b < a.brands_top.length; b++) f["brand_" + a.brands_top[b]] = inp.brand === a.brands_top[b] ? 1 : 0;
    // region dummies (reference: other)
    for (var r = 0; r < a.regions_top.length; r++) f["region_" + a.regions_top[r]] = inp.region === a.regions_top[r] ? 1 : 0;
    // priceType dummies (reference: NEGOTIABLE)
    var pts = ["FIXED", "NONE", "OFFERED"];
    for (var p = 0; p < pts.length; p++) f["priceType_" + pts[p]] = inp.priceType === pts[p] ? 1 : 0;
    // condition dummies (reference: like_new)
    var conds = ["good", "new", "refurb", "used"];
    for (var c = 0; c < conds.length; c++) f["condition_" + conds[c]] = inp.condition === conds[c] ? 1 : 0;
    return f;
  }

  /** b_cols-ordered float vector (the ONNX input). */
  function vecFromFeatures(f) {
    return ART.b_cols.map(function (c) { return f[c]; });
  }

  function runSession(session, vec48) {
    var t = new ort.Tensor("float32", Float32Array.from(vec48), [1, 48]);
    return session.run({ input: t }).then(function (o) {
      return { point: o.point.data[0], q10: o.q10.data[0], q90: o.q90.data[0] };
    });
  }

  /** predict.ts post-processing: smear, CQR Q, clamp, round to 100 DZD. */
  function postProcess(p_log, q10_log, q90_log) {
    var a = ART;
    var price = Math.exp(p_log) * a.smear;
    var lo = Math.exp(q10_log - a.cqr_Q_log) * a.smear;
    var hi = Math.exp(q90_log + a.cqr_Q_log) * a.smear;
    lo = Math.min(lo, price);   // display clamp: band must contain the point
    hi = Math.max(hi, price);   // (it can only widen the band, never shrink)
    var round100 = function (x) { return Math.round(x / 100) * 100; };
    return { price: round100(price), lo: round100(lo), hi: round100(hi), priceRaw: price };
  }

  async function predictAsync(inp, session) {
    var f = buildFeatures(inp);
    var o = await runSession(session, vecFromFeatures(f));
    var r = postProcess(o.point, o.q10, o.q90);
    r.features = f;
    r.modelVersion = ART.model_version;
    return r;
  }

  /** Parity bridge: raw 48-feature vector (b_cols order) -> UNROUNDED
   *  price/lo/hi — mirrors predict.ts predictPriceRawFromFeatures (no
   *  round100, no clamp) so fixture parity matches the app's 0.1% contract. */
  async function predictFromVectorAsync(vec48, session) {
    var o = await runSession(session, vec48);
    var a = ART;
    return {
      price: Math.exp(o.point) * a.smear,
      lo: Math.exp(o.q10 - a.cqr_Q_log) * a.smear,
      hi: Math.exp(o.q90 + a.cqr_Q_log) * a.smear,
    };
  }

  var api = { init: init, getArtifacts: getArtifacts, buildFeatures: buildFeatures,
              modelAgeYears: modelAgeYears, vecFromFeatures: vecFromFeatures,
              postProcess: postProcess, predictAsync: predictAsync,
              predictFromVectorAsync: predictFromVectorAsync };

  global.__spEngine = api;
  if (typeof module !== "undefined" && module.exports) module.exports = api;
})(typeof window !== "undefined" ? window : globalThis);