From 81e66a9b57eca4c5ce2fbfc4d37c564c8d12c7f7 Mon Sep 17 00:00:00 2001 From: LincPay Date: Sat, 15 Aug 2026 15:56:41 +0530 Subject: [PATCH] feat(admin): one-call demo bootstrap, and price simulation for the whole market A fresh deployment came up with a populated portfolio, an empty feeds screen and no alerts, because everything downstream of the seeder was an admin call whose order lived only in a runbook. POST /admin/bootstrap/demo now runs the six steps in dependency order and reports each one. The price simulator widens from the four commodities with no feed to every active basket pair, so price_z_score gets its three-year seasonal mean and TW-05 can fire with no DATAGOV_API_KEY present at all. Levels come from a per-crop table of ordinary mandi rates and the shape from the commodity group, because the group already encodes perishability - a vegetable moves on a weather event within days and a cereal does not, and flattening that would erase the one distinction the z-score exists to read. A pair holding any non-simulated row is skipped unless forced. That ordering is the safety property: run this after a real ingestion and it fills the gaps around the real series rather than over them. The orchestrator is deliberately not transactional. The seeder holds one transaction across its whole run, and when the machine slept mid-run nothing committed until morning while the console confidently served pre-seed state. A bootstrap generating three years of prices across every pair is long enough to hit exactly that, so each step commits alone and a failure in one leaves the others standing. Still not trainable, and coverage still says so. The seeder derives DPD from the band and the UPI simulator derives inflow from the same band, so a model fitted here recovers the seeder rule and reports fine metrics for doing it. Co-Authored-By: Claude Opus 5 (1M context) --- DEPLOYMENT.md | 44 +++--- .../admin/DemoBootstrapController.java | 44 ++++++ .../udyamlinc/admin/DemoBootstrapService.java | 140 +++++++++++++++++ .../ingestion/IngestionController.java | 21 +++ .../ingestion/MarketSignalRepository.java | 12 ++ .../NoFeedPriceSimulationService.java | 143 ++++++++++++++++++ 6 files changed, 386 insertions(+), 18 deletions(-) create mode 100644 src/main/java/com/lincfusion/udyamlinc/admin/DemoBootstrapController.java create mode 100644 src/main/java/com/lincfusion/udyamlinc/admin/DemoBootstrapService.java diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 4b66ff3..81a25b8 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -221,27 +221,34 @@ The seeder writes the portfolio, the ledger and the forecasts. It does **not** w loan history, UPI inflows or the no-feed price series: those are admin calls, so a fresh database comes up without them and several screens look thinner than they should. -Run these once, in order, after the seeder's final log line: +**One call does all of it.** After the seeder's final log line: ```bash BASE=http://localhost:8082/api/v1/udyam TOKEN= -# 1. DPD history — 24 monthly snapshots per loan, stamped SEEDED_HISTORY -curl -X POST -H "Authorization: Bearer $TOKEN" "$BASE/admin/loan-history/generate?months=24" - -# 2. UPI inflows — 26 weeks per enterprise, stamped SIMULATED. -# Stands in for Account Aggregator data until Sahamati FIU registration exists. -curl -X POST -H "Authorization: Bearer $TOKEN" "$BASE/admin/upi/simulate" - -# 3. Prices for the commodities with no API anywhere — milk, egg, fodder, sugarcane -curl -X POST -H "Authorization: Bearer $TOKEN" "$BASE/admin/ingestion/simulate/no-feed" - -# 4. Derive the features, then score against them -curl -X POST -H "Authorization: Bearer $TOKEN" "$BASE/admin/features/compute" -curl -X POST -H "Authorization: Bearer $TOKEN" "$BASE/admin/scoring/run" +curl -X POST -H "Authorization: Bearer $TOKEN" "$BASE/admin/bootstrap/demo?months=24" ``` +That runs six steps in dependency order and reports each one: + +| Step | What it writes | +|---|---| +| `loan-history` | 24 monthly DPD snapshots per loan, `SEEDED_HISTORY` | +| `upi-inflows` | 26 weeks per enterprise, `SIMULATED` — the Account Aggregator stand-in | +| `prices-no-feed` | milk, egg, fodder, sugarcane — the four with no API anywhere | +| `prices-all` | three years for **every remaining basket pair**, so `price_z_score` has a seasonal mean and TW-05 can fire without a data.gov.in key | +| `features` | the 15 derivations across the book | +| `scoring` | stress scores, bands, trip-wires, alerts | + +It is **idempotent** — re-running is safe, and each step commits on its own, so a +failure in one leaves the others intact and the response names which failed. + +The individual endpoints remain if you want to run a stage on its own: +`/admin/loan-history/generate`, `/admin/upi/simulate`, +`/admin/ingestion/simulate/no-feed`, `/admin/ingestion/simulate/all`, +`/admin/features/compute`, `/admin/scoring/run`. + Verify with the coverage endpoints, both of which report synthetic and real separately: ```bash @@ -264,10 +271,11 @@ Two consequences to state out loud rather than discover later: derives inflow drift from the same band, so a model trained on this would learn the seeder's own rule and validate beautifully while knowing nothing. The synthetic data is here to exercise the pipeline and fill the console, not to train. -- **This sequence does not give you the real mandi price history.** Steps 1–3 are - synthetic; the three-year Agmarknet backfill is real data and needs `DATAGOV_API_KEY` - plus a long one-off run, or a dump of a database that already has it. Without it - `price_z_score` has no seasonal mean and TW-05 stays quiet. +- **No external dependency at all.** The bootstrap needs no `DATAGOV_API_KEY`, no + Account Aggregator consent and no bank connection: every screen fills from generated + data. If a key *is* present, run the real ingestion first and the price simulator will + skip any pair that already holds real rows, filling the gaps around the real series + rather than across it. ## What is deliberately off diff --git a/src/main/java/com/lincfusion/udyamlinc/admin/DemoBootstrapController.java b/src/main/java/com/lincfusion/udyamlinc/admin/DemoBootstrapController.java new file mode 100644 index 0000000..2f5bcaf --- /dev/null +++ b/src/main/java/com/lincfusion/udyamlinc/admin/DemoBootstrapController.java @@ -0,0 +1,44 @@ +package com.lincfusion.udyamlinc.admin; + +import com.lincfusion.udyamlinc.common.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * One call to make a fresh deployment demonstrable. + * + *

Supervisor-only under the existing {@code /admin/**} write rule in + * SecurityConfig — this generates across the whole book and is not something a + * field officer should be able to trigger. + */ +@RestController +@RequestMapping("/admin/bootstrap") +@RequiredArgsConstructor +public class DemoBootstrapController { + + private final DemoBootstrapService bootstrapService; + + /** + * Generate loan history, UPI inflows and price series, then compute features + * and score the book. + * + *

Idempotent, and safe to re-run: the simulators upsert on a key that + * includes the source, and skip any pair a real feed has already written to. + * + * @param months months of DPD history per loan, default 24 + * @param force regenerate price series even where real data exists. Leave + * off unless you are rebuilding a demo database on purpose — + * the default is what protects a real ingested series. + */ + @PostMapping("/demo") + public ApiResponse demo( + @RequestParam(defaultValue = "24") int months, + @RequestParam(defaultValue = "false") boolean force) { + + DemoBootstrapService.Result result = bootstrapService.bootstrap(months, force); + return ApiResponse.ok(result.message(), result); + } +} diff --git a/src/main/java/com/lincfusion/udyamlinc/admin/DemoBootstrapService.java b/src/main/java/com/lincfusion/udyamlinc/admin/DemoBootstrapService.java new file mode 100644 index 0000000..c20d32e --- /dev/null +++ b/src/main/java/com/lincfusion/udyamlinc/admin/DemoBootstrapService.java @@ -0,0 +1,140 @@ +package com.lincfusion.udyamlinc.admin; + +import com.lincfusion.udyamlinc.enterprise.UpiSimulationService; +import com.lincfusion.udyamlinc.features.FeatureEngineeringService; +import com.lincfusion.udyamlinc.ingestion.NoFeedPriceSimulationService; +import com.lincfusion.udyamlinc.loanhistory.LoanHistoryService; +import com.lincfusion.udyamlinc.risk.RiskEngineService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; + +/** + * Brings a freshly seeded database up to a demonstrable state without any + * external dependency. + * + *

The seeder writes the portfolio, the ledger and the institutions. Everything + * downstream of that — loan history, UPI inflows, price series, features, scores — + * has always been an admin call, which is correct for a pilot and wrong for a demo + * server: a fresh deployment came up with a populated portfolio, an empty feeds + * screen and no alerts, and the sequence to fix it lived only in a runbook. + * + *

Every row this produces is generated, and every row says so in its own + * source column — {@code SEEDED_HISTORY} on loan records, {@code SIMULATED} on + * UPI and price rows. The simulators refuse to overwrite a non-generated row, so + * this can be run against a database holding real data and will fill the gaps + * around it rather than across it. + * + *

What this does not give you is a trainable dataset. The seeder derives + * DPD from the risk band and the UPI simulator derives inflow drift from the same + * band, so label and features share a cause we invented; a model fitted to this + * would recover the seeder's own rule and report excellent metrics for doing it. + * {@code /admin/loan-history/coverage} reports {@code trainable: false} for that + * reason and should keep saying so until a real lender extract exists. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class DemoBootstrapService { + + private final LoanHistoryService loanHistoryService; + private final UpiSimulationService upiSimulationService; + private final NoFeedPriceSimulationService priceSimulationService; + private final FeatureEngineeringService featureEngineeringService; + private final RiskEngineService riskEngine; + + public record Step(String name, boolean ok, String detail) { + } + + public record Result(List steps, boolean ok, String message) { + } + + /** + * Runs the whole sequence in dependency order. + * + *

Deliberately not {@code @Transactional}. Each step commits on its + * own, for a reason this project has already paid for once: the seeder holds a + * single transaction across its whole run, and when the machine slept mid-run + * nothing committed until the next morning — every figure read from the console + * in between was pre-seed state, reported confidently, with no warning + * anywhere. A bootstrap that generates three years of prices across every + * basket pair is long enough to hit exactly that. Committing per step also + * means a failure in scoring does not discard the price series that succeeded. + * + *

The cost is that a partial failure leaves partial state. That is the + * better trade here — every step is idempotent, so the fix is to run it again. + * + * @param months months of DPD history per loan + * @param force regenerate price series even where real data exists + */ + public Result bootstrap(int months, boolean force) { + List steps = new ArrayList<>(); + LocalDate today = LocalDate.now(); + + // Loan history and UPI first: both are inputs to the feature derivations, + // and computing features before them would write a snapshot full of nulls + // that the scoring run would then faithfully score as unknown. + steps.add(run("loan-history", () -> { + LoanHistoryService.GenerateResult r = loanHistoryService.generate(months); + return "%d loans · %d snapshots over %d months".formatted( + r.loans(), r.snapshotsWritten(), r.monthsRequested()); + })); + + steps.add(run("upi-inflows", () -> { + UpiSimulationService.SimulationResult r = upiSimulationService.simulate(false); + return "%d enterprises · %d weeks · %d skipped for consent".formatted( + r.enterprisesWritten(), r.weeksWritten(), r.skippedNoConsent()); + })); + + // The four commodities with no feed anywhere, then every remaining basket + // pair. Running the narrow one first keeps its hand-anchored levels — a + // milk rate with a citation behind it — rather than the group defaults. + steps.add(run("prices-no-feed", () -> priceSimulationService.simulate().message())); + steps.add(run("prices-all", () -> priceSimulationService.simulateAll(force).message())); + + steps.add(run("features", () -> { + FeatureEngineeringService.Summary s = featureEngineeringService.computeAll(today, null); + return "%d snapshots · %d null values".formatted(s.snapshots(), s.nullValues()); + })); + + steps.add(run("scoring", () -> { + RiskEngineService.Result r = riskEngine.scoreAll(today); + return "%d scored · %d trip-wires fired · %d alerts".formatted( + r.scored(), r.tripWiresFired(), r.alertsRaised()); + })); + + boolean ok = steps.stream().allMatch(Step::ok); + String message = ok + ? "Demo data generated. All figures are simulated and labelled as such." + : "Bootstrap finished with failures — see steps. Every step is idempotent; re-running is safe."; + log.info("Demo bootstrap — {}", message); + return new Result(steps, ok, message); + } + + /** + * One step, with its failure contained. + * + *

A step that throws must not abort the ones after it: prices do not depend + * on loan history, and losing the whole run because one generator failed would + * leave a demo server in a worse state than the partial one. + */ + private Step run(String name, StepBody body) { + try { + String detail = body.execute(); + log.info("Demo bootstrap · {} — {}", name, detail); + return new Step(name, true, detail); + } catch (Exception e) { + log.error("Demo bootstrap · {} failed", name, e); + return new Step(name, false, e.getClass().getSimpleName() + ": " + e.getMessage()); + } + } + + @FunctionalInterface + private interface StepBody { + String execute() throws Exception; + } +} diff --git a/src/main/java/com/lincfusion/udyamlinc/ingestion/IngestionController.java b/src/main/java/com/lincfusion/udyamlinc/ingestion/IngestionController.java index b9bb527..95f89e8 100644 --- a/src/main/java/com/lincfusion/udyamlinc/ingestion/IngestionController.java +++ b/src/main/java/com/lincfusion/udyamlinc/ingestion/IngestionController.java @@ -48,6 +48,27 @@ public class IngestionController { return ApiResponse.ok(result.message(), result); } + /** + * Three years of weekly prices for every active basket pair that has no real + * data — the whole market, not just the four commodities without a feed. + * + *

For a deployment with no {@code DATAGOV_API_KEY}. A pair that already + * holds real rows is skipped rather than overwritten, so running this against + * a database that has ingested Agmarknet fills the gaps around it and leaves + * the real series alone. + * + * @param force generate even over a real series. For rebuilding a demo + * database on purpose; the rows still carry {@code SIMULATED} + * and still sit beside the real ones rather than replacing them. + */ + @PostMapping("/simulate/all") + public ApiResponse simulateAll( + @RequestParam(defaultValue = "false") boolean force) { + NoFeedPriceSimulationService.FullSimulationResult result = + noFeedPriceSimulationService.simulateAll(force); + return ApiResponse.ok(result.message(), result); + } + /** * One call per basket pair, reporting which spellings actually match the * feed. A write only in the {@code persist=true} sense — it sets the diff --git a/src/main/java/com/lincfusion/udyamlinc/ingestion/MarketSignalRepository.java b/src/main/java/com/lincfusion/udyamlinc/ingestion/MarketSignalRepository.java index aebf4da..9f99f6d 100644 --- a/src/main/java/com/lincfusion/udyamlinc/ingestion/MarketSignalRepository.java +++ b/src/main/java/com/lincfusion/udyamlinc/ingestion/MarketSignalRepository.java @@ -16,6 +16,18 @@ public interface MarketSignalRepository extends JpaRepository findByDistrictIdAndCommodityIdOrderByWeekStartDesc(Long districtId, Long commodityId); + /** + * How many rows for this pair came from somewhere other than the given source. + * + *

Used by the simulator to leave a pair alone when a real feed has already + * written to it. The test is deliberately "any row at all" rather than "a row + * this week": a district that ingested real Agmarknet prices and then went + * quiet for a fortnight still has a real series, and burying it under a + * generated one to fill the gap would be the one thing the source column + * exists to prevent. + */ + long countByDistrictIdAndCommodityIdAndSourceNot(Long districtId, Long commodityId, String source); + List findByWeekStartGreaterThanEqualOrderByWeekStartDesc(LocalDate from); /** diff --git a/src/main/java/com/lincfusion/udyamlinc/ingestion/NoFeedPriceSimulationService.java b/src/main/java/com/lincfusion/udyamlinc/ingestion/NoFeedPriceSimulationService.java index 82831bf..7785546 100644 --- a/src/main/java/com/lincfusion/udyamlinc/ingestion/NoFeedPriceSimulationService.java +++ b/src/main/java/com/lincfusion/udyamlinc/ingestion/NoFeedPriceSimulationService.java @@ -3,6 +3,8 @@ package com.lincfusion.udyamlinc.ingestion; import com.lincfusion.udyamlinc.master.Commodity; import com.lincfusion.udyamlinc.master.CommodityRepository; import com.lincfusion.udyamlinc.master.District; +import com.lincfusion.udyamlinc.master.DistrictCommodity; +import com.lincfusion.udyamlinc.master.DistrictCommodityRepository; import com.lincfusion.udyamlinc.master.DistrictRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -130,13 +132,154 @@ public class NoFeedPriceSimulationService { // and peaks with the post-harvest cotton arrival window. "YARN_INDEX", new Profile(100.0, 0.11, 0.015, 6, 0.065)); + /** + * Farm-gate levels in ₹/quintal for the commodities that normally arrive from + * Agmarknet, used only when no real row exists for a pair. + * + *

These are ordinary Indian mandi levels rather than invented numbers, for + * the same reason the profiles above are anchored: an officer sanity-checks a + * price on sight, and a plausible shape carrying an implausible level is worse + * than an obvious placeholder because it invites belief. + * + *

They are still approximate, and a simulated row says so in its source + * column. Nothing here is a substitute for the real feed — it is what keeps + * the console populated on a deployment that has no data.gov.in key. + */ + private static final Map CROP_BASE = Map.ofEntries( + // Cereals — procurement-anchored, so the least volatile group. + Map.entry("WHEAT", 2550.0), Map.entry("PADDY", 2320.0), Map.entry("RICE", 3800.0), + Map.entry("MAIZE", 2150.0), Map.entry("BAJRA", 2600.0), Map.entry("JOWAR", 3300.0), + // Pulses + Map.entry("GRAM", 5900.0), Map.entry("MASOOR", 6400.0), + Map.entry("MOONG", 8500.0), Map.entry("ARHAR", 7600.0), + // Oilseeds and cash crops + Map.entry("SOYA", 4900.0), Map.entry("MUSTARD", 5800.0), Map.entry("COTTON", 7500.0), + // Vegetables — the volatile end, and where margin_squeeze earns its keep. + Map.entry("ONION", 1900.0), Map.entry("TOMATO", 2100.0), Map.entry("POTATO", 1500.0), + Map.entry("BRINJAL", 2200.0), Map.entry("CAULIFLOWER", 1900.0), Map.entry("CABBAGE", 1400.0), + Map.entry("GREEN_CHILLI", 4600.0), Map.entry("OKRA", 3100.0), Map.entry("CAPSICUM", 4200.0), + Map.entry("CUCUMBER", 1700.0), Map.entry("GREEN_PEAS", 4400.0), Map.entry("PUMPKIN", 1250.0), + Map.entry("BEETROOT", 2050.0), Map.entry("BITTER_GOURD", 3300.0), + Map.entry("BOTTLE_GOURD", 1450.0), Map.entry("CARROT", 2300.0), + // Fruit + Map.entry("BANANA", 2100.0), Map.entry("MANGO", 5200.0), Map.entry("GRAPES", 6200.0), + Map.entry("POMEGRANATE", 9500.0), Map.entry("LITCHI", 7200.0), + Map.entry("PAPAYA", 1850.0), Map.entry("GUAVA", 3200.0), + // Spices + Map.entry("GARLIC", 9200.0), Map.entry("GINGER", 6800.0), Map.entry("TURMERIC", 8000.0)); + + /** + * Seasonal swing, annual trend and weekly noise by commodity group. + * + *

The group already encodes perishability — that is why the column exists + * rather than as a display nicety — and perishability is exactly what decides + * volatility. A vegetable price moves on a weather event within days; a cereal + * price does not, because procurement puts a floor under it. Giving both the + * same shape would flatten the one distinction {@code price_z_score} is there + * to read. + * + *

Noise is set relative to the trend throughout, per the calibration note + * on {@link Profile}: real Agmarknet series in this same database average a + * z of 0.44, and a generator that trends faster than it varies puts every + * newest observation at the top of its own distribution. + */ + private record GroupShape(double seasonalAmplitude, double trendPerYear, double weeklyNoise) { + } + + private static final Map GROUP_SHAPES = Map.of( + "CEREAL", new GroupShape(0.07, 0.020, 0.030), + "PULSE", new GroupShape(0.11, 0.020, 0.042), + "OILSEED", new GroupShape(0.10, 0.020, 0.040), + "CASH", new GroupShape(0.09, 0.020, 0.038), + "VEGETABLE", new GroupShape(0.26, 0.015, 0.085), + "FRUIT", new GroupShape(0.22, 0.015, 0.070), + "SPICE", new GroupShape(0.20, 0.020, 0.065)); + + private static final GroupShape DEFAULT_SHAPE = new GroupShape(0.12, 0.020, 0.045); + private final CommodityRepository commodityRepository; private final DistrictRepository districtRepository; + private final DistrictCommodityRepository districtCommodityRepository; private final MarketSignalRepository signalRepository; public record SimulationResult(int commodities, int districts, int weeksWritten, String message) { } + public record FullSimulationResult(int pairsWritten, int pairsSkipped, int weeksWritten, String message) { + } + + /** + * A three-year series for every active basket pair that has no real data. + * + *

This exists for a deployment with no {@code DATAGOV_API_KEY} — a demo + * server, most immediately. Without it a fresh database has twelve weeks of + * nothing: {@code price_z_score} needs a seasonal mean built from three years + * of the same week in the same district, so a short or empty series does not + * merely weaken the feature, it makes it null, and TW-05 never fires. + * + *

A pair with any non-simulated row is skipped unless {@code force} + * is set. That ordering is the whole safety property: run this on a database + * that has already ingested real Agmarknet prices and it fills the gaps + * around them rather than papering over them. The rows it does write carry + * {@code source = SIMULATED} and key on the source, so even a forced run sits + * beside the real series instead of replacing it. + * + * @param force write even where a real series already exists — for rebuilding + * a demo database deliberately, never as a default + */ + @Transactional + public FullSimulationResult simulateAll(boolean force) { + LocalDate thisWeek = Weeks.startOf(LocalDate.now()); + LocalDateTime observedAt = LocalDateTime.now(); + + int pairs = 0, skipped = 0, written = 0; + + for (DistrictCommodity pair : districtCommodityRepository.findByActiveTrue()) { + District district = districtRepository.findById(pair.getDistrictId()).orElse(null); + Commodity commodity = commodityRepository.findById(pair.getCommodityId()).orElse(null); + if (district == null || commodity == null) { + continue; + } + if (!force && signalRepository.countByDistrictIdAndCommodityIdAndSourceNot( + district.getId(), commodity.getId(), SOURCE) > 0) { + skipped++; + continue; + } + written += writeSeries(district, commodity, profileFor(commodity), thisWeek, observedAt); + pairs++; + } + + String message = "%d basket pairs simulated, %d left to real data · %d weeks written" + .formatted(pairs, skipped, written); + log.info("Full market simulation — {}", message); + return new FullSimulationResult(pairs, skipped, written, message); + } + + /** + * The profile to generate a commodity's series from. + * + *

An explicit hand-anchored profile wins; otherwise the level comes from + * {@link #CROP_BASE} and the shape from the commodity's group. + * + *

The peak week is derived from the commodity code rather than from a crop + * calendar. That is a real limitation and worth naming: the series will have a + * consistent, deterministic season, but not necessarily the season — + * onion's simulated peak is not guaranteed to fall in the actual rabi arrival + * window. It matters for realism rather than for correctness, since every + * derivation reads the series against itself. Replacing this with a sowing and + * harvest calendar is the obvious improvement once one is on hand. + */ + private Profile profileFor(Commodity commodity) { + Profile explicit = PROFILES.get(commodity.getCode()); + if (explicit != null) { + return explicit; + } + GroupShape shape = GROUP_SHAPES.getOrDefault(commodity.getCommodityGroup(), DEFAULT_SHAPE); + double base = CROP_BASE.getOrDefault(commodity.getCode(), 3000.0); + int peakWeek = Math.floorMod(commodity.getCode().hashCode(), 52) + 1; + return new Profile(base, shape.seasonalAmplitude(), shape.trendPerYear(), peakWeek, shape.weeklyNoise()); + } + @Transactional public SimulationResult simulate() { LocalDate thisWeek = Weeks.startOf(LocalDate.now());