nl2time 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,6 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ .pytest_cache/
5
+ dist/
6
+ *.egg-info/
nl2time-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 nl2time contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
nl2time-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,73 @@
1
+ Metadata-Version: 2.4
2
+ Name: nl2time
3
+ Version: 0.1.0
4
+ Summary: Deterministic temporal-expression IR engine — Python port of the nl2time natural language ⇄ date/time library
5
+ Project-URL: Homepage, https://github.com/AndyFooBlah/nl2time
6
+ Project-URL: Repository, https://github.com/AndyFooBlah/nl2time
7
+ Project-URL: Changelog, https://github.com/AndyFooBlah/nl2time/blob/main/CHANGELOG.md
8
+ Project-URL: Issues, https://github.com/AndyFooBlah/nl2time/issues
9
+ Author: Andrew Brook
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: datetime,llm,natural-language,parser,temporal,timezone
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Software Development :: Libraries
20
+ Requires-Python: >=3.11
21
+ Requires-Dist: whenever>=0.8
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest>=8; extra == 'dev'
24
+ Description-Content-Type: text/markdown
25
+
26
+ # nl2time (Python)
27
+
28
+ Deterministic resolution of temporal-expression IR — the Python port of
29
+ [nl2time](https://github.com/AndyFooBlah/nl2time), a bidirectional natural
30
+ language ⇄ date/time library built around a JSON intermediate representation
31
+ (`TimeExpr`).
32
+
33
+ **Scope of this release: the language-neutral core.** IR validation,
34
+ `TimeContext` (reference instant, IANA timezone, locale-derived week/date
35
+ policy, ambiguity knobs), and the full deterministic engine `resolve()` —
36
+ every operator including calendar snapping, DST-correct offsets, holidays
37
+ (computed Easter), business-day spans, and ordered ambiguity candidates.
38
+ Natural-language *parsing* and *rendering* are not yet ported; today the
39
+ expected producers of IR are the JS parser, an LLM emitting schema-constrained
40
+ IR, or your own code. Time model: [whenever](https://pypi.org/project/whenever/)
41
+ (Temporal-inspired).
42
+
43
+ ```python
44
+ from nl2time import TimeContext, resolve
45
+
46
+ ctx = TimeContext.make({
47
+ "now": "2026-07-20T17:00:00Z",
48
+ "timeZone": "America/Los_Angeles",
49
+ "locale": "en-US",
50
+ })
51
+
52
+ # "last week" as IR (e.g. produced by the JS parser or an LLM):
53
+ expr = {
54
+ "op": "snap",
55
+ "base": {"op": "offset", "base": {"op": "now"}, "amount": -1, "unit": "week"},
56
+ "unit": "week",
57
+ }
58
+ resolve(expr, ctx)["candidates"][0]
59
+ # {"kind": "interval", "start": Instant("2026-07-12 07:00:00Z"),
60
+ # "end": Instant("2026-07-19 07:00:00Z"), "grain": "week"}
61
+ # (values are `whenever.Instant`; candidates are ordered, ambiguity is data)
62
+ ```
63
+
64
+ ## Parity guarantee
65
+
66
+ This engine reproduces the JS reference **bit-exactly** — instants, grains,
67
+ and candidate order — across all 2,760 machine-generated engine-parity
68
+ fixtures derived from the project's six-language conformance corpora, enforced
69
+ in CI on every commit. Spec: [ir-spec.md](https://github.com/AndyFooBlah/nl2time/blob/main/docs/ir-spec.md);
70
+ strategy: [porting.md](https://github.com/AndyFooBlah/nl2time/blob/main/docs/porting.md);
71
+ test-data provenance: [corpus/ATTRIBUTIONS.md](https://github.com/AndyFooBlah/nl2time/blob/main/corpus/ATTRIBUTIONS.md).
72
+
73
+ MIT.
@@ -0,0 +1,48 @@
1
+ # nl2time (Python)
2
+
3
+ Deterministic resolution of temporal-expression IR — the Python port of
4
+ [nl2time](https://github.com/AndyFooBlah/nl2time), a bidirectional natural
5
+ language ⇄ date/time library built around a JSON intermediate representation
6
+ (`TimeExpr`).
7
+
8
+ **Scope of this release: the language-neutral core.** IR validation,
9
+ `TimeContext` (reference instant, IANA timezone, locale-derived week/date
10
+ policy, ambiguity knobs), and the full deterministic engine `resolve()` —
11
+ every operator including calendar snapping, DST-correct offsets, holidays
12
+ (computed Easter), business-day spans, and ordered ambiguity candidates.
13
+ Natural-language *parsing* and *rendering* are not yet ported; today the
14
+ expected producers of IR are the JS parser, an LLM emitting schema-constrained
15
+ IR, or your own code. Time model: [whenever](https://pypi.org/project/whenever/)
16
+ (Temporal-inspired).
17
+
18
+ ```python
19
+ from nl2time import TimeContext, resolve
20
+
21
+ ctx = TimeContext.make({
22
+ "now": "2026-07-20T17:00:00Z",
23
+ "timeZone": "America/Los_Angeles",
24
+ "locale": "en-US",
25
+ })
26
+
27
+ # "last week" as IR (e.g. produced by the JS parser or an LLM):
28
+ expr = {
29
+ "op": "snap",
30
+ "base": {"op": "offset", "base": {"op": "now"}, "amount": -1, "unit": "week"},
31
+ "unit": "week",
32
+ }
33
+ resolve(expr, ctx)["candidates"][0]
34
+ # {"kind": "interval", "start": Instant("2026-07-12 07:00:00Z"),
35
+ # "end": Instant("2026-07-19 07:00:00Z"), "grain": "week"}
36
+ # (values are `whenever.Instant`; candidates are ordered, ambiguity is data)
37
+ ```
38
+
39
+ ## Parity guarantee
40
+
41
+ This engine reproduces the JS reference **bit-exactly** — instants, grains,
42
+ and candidate order — across all 2,760 machine-generated engine-parity
43
+ fixtures derived from the project's six-language conformance corpora, enforced
44
+ in CI on every commit. Spec: [ir-spec.md](https://github.com/AndyFooBlah/nl2time/blob/main/docs/ir-spec.md);
45
+ strategy: [porting.md](https://github.com/AndyFooBlah/nl2time/blob/main/docs/porting.md);
46
+ test-data provenance: [corpus/ATTRIBUTIONS.md](https://github.com/AndyFooBlah/nl2time/blob/main/corpus/ATTRIBUTIONS.md).
47
+
48
+ MIT.
@@ -0,0 +1,24 @@
1
+ """nl2time: natural language <-> dates/times, language-neutral IR core.
2
+
3
+ Python port of the JS reference (see docs/porting.md in the repository).
4
+ v0: IR types + structural validator, TimeContext, and the deterministic
5
+ engine `resolve()`. Parsers and describe() land in later phases.
6
+ """
7
+
8
+ from . import errors
9
+ from .context import TimeContext
10
+ from .engine import MAX_CANDIDATES, resolve, resolve_one, value_to_json
11
+ from .ir import IR_VERSION, validate_expr
12
+
13
+ __all__ = [
14
+ "IR_VERSION",
15
+ "MAX_CANDIDATES",
16
+ "TimeContext",
17
+ "errors",
18
+ "resolve",
19
+ "resolve_one",
20
+ "validate_expr",
21
+ "value_to_json",
22
+ ]
23
+
24
+ __version__ = "0.0.1"
@@ -0,0 +1,167 @@
1
+ """The clock seam: every `whenever` usage in nl2time flows through this module.
2
+
3
+ Python counterpart of src/clock/index.ts (which wraps Temporal):
4
+ Temporal.Instant -> whenever.Instant, Temporal.ZonedDateTime ->
5
+ whenever.ZonedDateTime. Semantics mirrored:
6
+
7
+ - calendar units (day and coarser) add/subtract preserving wall-clock time
8
+ across DST, with Temporal-'compatible' disambiguation;
9
+ - hour/minute/second arithmetic is exact (absolute time);
10
+ - month/year arithmetic clamps day-of-month (Jan 31 + 1 month = Feb 28/29).
11
+
12
+ No other module may import `whenever` directly.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass
18
+
19
+ from whenever import Date, Instant, ZonedDateTime
20
+
21
+ WEEKDAY_NUM: dict[str, int] = {
22
+ "mon": 1,
23
+ "tue": 2,
24
+ "wed": 3,
25
+ "thu": 4,
26
+ "fri": 5,
27
+ "sat": 6,
28
+ "sun": 7,
29
+ }
30
+
31
+
32
+ def weekday_number(w: str) -> int:
33
+ return WEEKDAY_NUM[w]
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class ZInterval:
38
+ """Half-open zoned interval [start, end) carrying its granularity."""
39
+
40
+ start: ZonedDateTime
41
+ end: ZonedDateTime
42
+ grain: str
43
+
44
+
45
+ def system_now() -> Instant:
46
+ return Instant.now()
47
+
48
+
49
+ def system_time_zone() -> str:
50
+ return ZonedDateTime.now_in_system_tz().tz
51
+
52
+
53
+ def parse_instant(s: str) -> Instant:
54
+ return Instant.parse_iso(s)
55
+
56
+
57
+ def format_instant(i: Instant) -> str:
58
+ """Temporal.Instant.toString()-compatible: `Z` suffix, trailing zeros trimmed."""
59
+ return i.format_iso()
60
+
61
+
62
+ def to_zoned(instant: Instant, time_zone: str) -> ZonedDateTime:
63
+ return instant.to_tz(time_zone)
64
+
65
+
66
+ def point_interval(z: ZonedDateTime) -> ZInterval:
67
+ """Point interval at z (grain 'instant')."""
68
+ return ZInterval(z, z, "instant")
69
+
70
+
71
+ def start_of_day(z: ZonedDateTime) -> ZonedDateTime:
72
+ return z.start_of("day")
73
+
74
+
75
+ def with_fields(
76
+ z: ZonedDateTime,
77
+ *,
78
+ year: int | None = None,
79
+ month: int | None = None,
80
+ day: int | None = None,
81
+ ) -> ZonedDateTime:
82
+ """Temporal `.with({year?, month?, day?})` with 'constrain' overflow.
83
+
84
+ whenever's replace() raises on out-of-range days; Temporal clamps
85
+ (e.g. `.with({month: 2, day: 31})` on Jan 15 -> Feb 28). Clamp manually.
86
+ """
87
+ y = year if year is not None else z.year
88
+ m = month if month is not None else z.month
89
+ d = day if day is not None else z.day
90
+ d = min(d, Date(y, m, 1).days_in_month())
91
+ return z.replace(year=y, month=m, day=d, disambiguate="compatible")
92
+
93
+
94
+ def add_calendar(z: ZonedDateTime, **units: int) -> ZonedDateTime:
95
+ """Calendar-aware add with Temporal-'compatible' disambiguation."""
96
+ return z.add(**units, disambiguate="compatible")
97
+
98
+
99
+ def floor_to(z: ZonedDateTime, unit: str, week_start: str) -> ZonedDateTime:
100
+ if unit == "second":
101
+ return z.round("second", mode="floor")
102
+ if unit == "minute":
103
+ return z.round("minute", mode="floor")
104
+ if unit == "hour":
105
+ return z.round("hour", mode="floor")
106
+ if unit == "day":
107
+ return z.start_of("day")
108
+ if unit == "week":
109
+ target = WEEKDAY_NUM[week_start]
110
+ # day_of_week is ISO (mon=1..sun=7); step back to the week-start day.
111
+ delta = (z.date().day_of_week().value - target + 7) % 7
112
+ return z.start_of("day").subtract(days=delta, disambiguate="compatible")
113
+ if unit == "month":
114
+ return with_fields(z, day=1).start_of("day")
115
+ if unit == "quarter":
116
+ q_start_month = (z.month - 1) // 3 * 3 + 1
117
+ return with_fields(z, month=q_start_month, day=1).start_of("day")
118
+ if unit == "year":
119
+ return with_fields(z, month=1, day=1).start_of("day")
120
+ raise ValueError(f"unknown unit {unit!r}")
121
+
122
+
123
+ def add_units(z: ZonedDateTime, unit: str, n: int) -> ZonedDateTime:
124
+ """Calendar-aware unit addition (day+ preserves wall clock; h/m/s exact)."""
125
+ if unit == "second":
126
+ return z.add(seconds=n)
127
+ if unit == "minute":
128
+ return z.add(minutes=n)
129
+ if unit == "hour":
130
+ return z.add(hours=n)
131
+ if unit == "day":
132
+ return add_calendar(z, days=n)
133
+ if unit == "week":
134
+ return add_calendar(z, weeks=n)
135
+ if unit == "month":
136
+ return add_calendar(z, months=n)
137
+ if unit == "quarter":
138
+ return add_calendar(z, months=3 * n)
139
+ if unit == "year":
140
+ return add_calendar(z, years=n)
141
+ raise ValueError(f"unknown unit {unit!r}")
142
+
143
+
144
+ def containing_unit(z: ZonedDateTime, unit: str, week_start: str) -> ZInterval:
145
+ """The containing unit interval of z: floor, extend one unit."""
146
+ start = floor_to(z, unit, week_start)
147
+ return ZInterval(start, add_units(start, unit, 1), unit)
148
+
149
+
150
+ def calendar_days_between(from_z: ZonedDateTime, to_z: ZonedDateTime) -> int:
151
+ """Whole calendar days between two zoned datetimes (calendar difference)."""
152
+ return int(from_z.start_of("day").date().until(to_z.start_of("day").date(), total="days"))
153
+
154
+
155
+ def compare(a: ZonedDateTime, b: ZonedDateTime) -> int:
156
+ """Instant-order comparison (Temporal.ZonedDateTime.compare)."""
157
+ x = a.timestamp_nanos()
158
+ y = b.timestamp_nanos()
159
+ return -1 if x < y else (1 if x > y else 0)
160
+
161
+
162
+ def epoch_millis(z: ZonedDateTime) -> int:
163
+ return z.timestamp_millis()
164
+
165
+
166
+ def add_milliseconds(z: ZonedDateTime, ms: int) -> ZonedDateTime:
167
+ return z.add(milliseconds=ms)
@@ -0,0 +1,116 @@
1
+ """TimeContext: all resolution policy flows through here (port of src/context.ts).
2
+
3
+ Options use the same camelCase names as the JS reference and the corpus wire
4
+ format (`timeZone`, `weekStart`, ...); attributes are snake_case.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ from whenever import Instant, ZonedDateTime
12
+
13
+ from .clock import parse_instant, system_now, system_time_zone, to_zoned
14
+ from .data import DayPeriodRule, first_day_for_region
15
+ from .errors import ConfigError
16
+
17
+ _MDY_REGIONS = frozenset(["US", "PH", "UM", "VI", "GU", "AS", "PR"])
18
+ _YMD_REGIONS = frozenset(["CN", "JP", "KR", "TW", "HU", "MN", "LT"])
19
+
20
+ _WEEKDAYS = frozenset(["mon", "tue", "wed", "thu", "fri", "sat", "sun"])
21
+
22
+
23
+ def _parse_locale(locale: str) -> tuple[str, str | None]:
24
+ """Split a BCP-47 tag without Intl: language = first subtag; region = the
25
+ first 2-letter-alpha or 3-digit subtag after it (script subtags skipped)."""
26
+ parts = locale.replace("_", "-").split("-")
27
+ language = parts[0].lower()
28
+ if not language or not language.isascii() or not language.isalpha():
29
+ raise ConfigError(f"invalid locale: {locale}")
30
+ region: str | None = None
31
+ for sub in parts[1:]:
32
+ if (len(sub) == 2 and sub.isascii() and sub.isalpha()) or (
33
+ len(sub) == 3 and sub.isdigit()
34
+ ):
35
+ region = sub.upper()
36
+ break
37
+ return language, region
38
+
39
+
40
+ def _normalize_day_periods(rules: list[dict[str, Any]]) -> list[DayPeriodRule]:
41
+ """Accept wire-format rules ({period, from, before}) or internal from_."""
42
+ out: list[DayPeriodRule] = []
43
+ for r in rules:
44
+ out.append(
45
+ {
46
+ "period": r["period"],
47
+ "from_": r["from_"] if "from_" in r else r["from"],
48
+ "before": r["before"],
49
+ }
50
+ )
51
+ return out
52
+
53
+
54
+ class TimeContext:
55
+ def __init__(
56
+ self,
57
+ *,
58
+ now: Instant | str | None = None,
59
+ timeZone: str | None = None,
60
+ locale: str | None = None,
61
+ weekStart: str | None = None,
62
+ dateOrder: str | None = None,
63
+ bias: str | None = None,
64
+ nextWeekday: str | None = None,
65
+ partialPeriod: str | None = None,
66
+ dayPeriods: list[dict[str, Any]] | None = None,
67
+ ) -> None:
68
+ raw_now = now if now is not None else system_now()
69
+ try:
70
+ self.now: Instant = (
71
+ parse_instant(raw_now) if isinstance(raw_now, str) else raw_now
72
+ )
73
+ except Exception as e: # noqa: BLE001 - mirror JS ConfigError wrapping
74
+ raise ConfigError(f"invalid now: {e}") from e
75
+ self.time_zone: str = timeZone if timeZone is not None else system_time_zone()
76
+ self.locale: str = locale if locale is not None else "en-US"
77
+
78
+ self.language, self.region = _parse_locale(self.locale)
79
+
80
+ if weekStart is not None and weekStart not in _WEEKDAYS:
81
+ raise ConfigError(f"invalid weekStart: {weekStart}")
82
+ self.week_start: str = (
83
+ weekStart if weekStart is not None else first_day_for_region(self.region)
84
+ )
85
+ if dateOrder is not None:
86
+ self.date_order = dateOrder
87
+ elif self.region in _MDY_REGIONS:
88
+ self.date_order = "MDY"
89
+ elif self.region in _YMD_REGIONS:
90
+ self.date_order = "YMD"
91
+ else:
92
+ self.date_order = "DMY"
93
+ self.bias: str = bias if bias is not None else "none"
94
+ self.next_weekday: str = nextWeekday if nextWeekday is not None else "nearest"
95
+ self.partial_period: str = (
96
+ partialPeriod if partialPeriod is not None else "include"
97
+ )
98
+ self.day_periods: list[DayPeriodRule] | None = (
99
+ _normalize_day_periods(dayPeriods) if dayPeriods is not None else None
100
+ )
101
+
102
+ # Validate the timezone eagerly so failures surface at construction.
103
+ try:
104
+ to_zoned(self.now, self.time_zone)
105
+ except Exception as e: # noqa: BLE001
106
+ raise ConfigError(f"invalid timeZone: {self.time_zone}") from e
107
+
108
+ @classmethod
109
+ def make(cls, opts: dict[str, Any] | None = None, /, **kwargs: Any) -> "TimeContext":
110
+ merged = {**(opts or {}), **kwargs}
111
+ return cls(**merged)
112
+
113
+ @property
114
+ def zoned_now(self) -> ZonedDateTime:
115
+ """The reference instant projected into the context timezone."""
116
+ return to_zoned(self.now, self.time_zone)
@@ -0,0 +1,73 @@
1
+ """Locale data tables: CLDR weekData slice and day-period boundary rules.
2
+
3
+ Direct port of src/data/weekData.ts and src/data/dayPeriods.ts from the JS
4
+ reference. Keep in sync; these are near-pure data.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import TypedDict
10
+
11
+ DEFAULT_FIRST_DAY = "mon"
12
+
13
+ _SUN = frozenset([
14
+ "AG", "AS", "BD", "BR", "BS", "BT", "BW", "BZ", "CA", "CO", "DM", "DO", "ET",
15
+ "GT", "GU", "HK", "HN", "ID", "IL", "IN", "JM", "JP", "KE", "KH", "KR", "LA",
16
+ "MH", "MM", "MO", "MT", "MX", "MZ", "NI", "NP", "PA", "PE", "PH", "PK", "PR",
17
+ "PT", "PY", "SA", "SG", "SV", "TH", "TT", "TW", "UM", "US", "VE", "VI", "WS",
18
+ "YE", "ZA", "ZW",
19
+ ])
20
+
21
+ _SAT = frozenset([
22
+ "AE", "AF", "BH", "DJ", "DZ", "EG", "IQ", "IR", "JO", "KW", "LY", "OM", "QA",
23
+ "SD", "SY",
24
+ ])
25
+
26
+ _FRI = frozenset(["MV"])
27
+
28
+
29
+ def first_day_for_region(region: str | None) -> str:
30
+ if not region:
31
+ return DEFAULT_FIRST_DAY
32
+ r = region.upper()
33
+ if r in _SUN:
34
+ return "sun"
35
+ if r in _SAT:
36
+ return "sat"
37
+ if r in _FRI:
38
+ return "fri"
39
+ return DEFAULT_FIRST_DAY
40
+
41
+
42
+ class DayPeriodRule(TypedDict):
43
+ """Boundaries are [from, before) in local hours; may wrap midnight."""
44
+
45
+ period: str
46
+ from_: int # start hour (inclusive); key name avoids the Python keyword
47
+ before: int # end hour (exclusive); may be < from_ (wraps midnight)
48
+
49
+
50
+ # CLDR flexible day-period rules, English set (v1 ships en only; other
51
+ # languages fall back to these boundaries, mirroring the JS reference).
52
+ EN_RULES: list[DayPeriodRule] = [
53
+ {"period": "morning", "from_": 6, "before": 12},
54
+ {"period": "afternoon", "from_": 12, "before": 18},
55
+ {"period": "evening", "from_": 18, "before": 21},
56
+ {"period": "night", "from_": 21, "before": 6},
57
+ ]
58
+
59
+
60
+ def day_period_rules(language: str) -> list[DayPeriodRule]:
61
+ # Only 'en' data is bundled so far; other languages fall back (see JS ref).
62
+ del language
63
+ return EN_RULES
64
+
65
+
66
+ def period_for_hour(language: str, hour: int) -> str:
67
+ for rule in day_period_rules(language):
68
+ if rule["from_"] < rule["before"]:
69
+ if rule["from_"] <= hour < rule["before"]:
70
+ return rule["period"]
71
+ elif hour >= rule["from_"] or hour < rule["before"]:
72
+ return rule["period"]
73
+ return "night"