Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -532,7 +532,7 @@ Called like functions, compiled to dedicated opcodes.
| `srt xs` | sort list (all-number or all-text) or text chars (stable: equal elements keep their input order) | same type |
| `srt fn xs` | sort list by key function (returns number or text key); stable: items with equal keys keep their input order | `L` |
| `unq xs` | remove duplicates, preserve order (list or text chars) | same type |
| `slc xs a b` | slice list or text from index a to b (a, b accept negative indices counting from end; bounds clamp) | same type |
| `slc xs a b` | slice list or text from index a to b (a, b accept negative indices counting from end; bounds clamp). Sugar: when `a >= 0` and `b == -1`, `b` reads as `len xs` (Python/JS "to end" shape). Other negative `b` values (e.g. `-2`) keep the relative-offset semantics; use `take -1 xs` if you want "drop last". | same type |
| `jpth json path` | JSON dot-path lookup, dot-separated keys + numeric array indices (e.g. `"a.b.0.c"`), not JSONPath - leading `$`, `*`, or `[...]` rejected with a diagnostic. Result is typed: arrays → list, objects → record, scalars → matching primitive. | `R _ t` |
| `jkeys json path` | sorted top-level keys of the JSON object at `path` (empty path = root). Err if the value at the path is not an object. | `R (L t) t` |
| `jdmp value` | serialise ilo value to JSON text | `t` |
Expand Down
2 changes: 1 addition & 1 deletion ai.txt

Large diffs are not rendered by default.

25 changes: 14 additions & 11 deletions examples/negative-indices.ilo
Original file line number Diff line number Diff line change
@@ -1,25 +1,26 @@
-- Negative indices on slice-shaped builtins count from the end of the
-- list or text, matching Python semantics already in place for `at xs i`.
-- list or text, matching Python semantics already in place for `at xs i`,
-- with one ergonomic exception on `slc`'s end bound (see below).
--
-- slc xs s e accepts negative s and e. `slc xs -1 (len xs)` is the
-- last element as a 1-element list; `slc xs 0 -1` drops
-- the last element; bounds clamp, never wrap.
-- last element as a 1-element list. `slc xs -3 -1` keeps
-- Python-style semantics when start is negative (the
-- penultimate window). When start is non-negative and
-- end is exactly -1, the end is read as "to end of
-- list/text" (see `slc-to-end.ilo`).
-- take n xs with n<0 keeps all but the last |n|. Equivalent to
-- Python's xs[:n]. `take -1 [10,20,30]` is [10,20].
-- Python's xs[:n]. `take -1 [10,20,30]` is [10,20]. Use
-- this when you want to "drop the last element".
-- drop n xs with n<0 keeps only the last |n|. Equivalent to
-- Python's xs[n:]. `drop -1 [10,20,30]` is [30].
--
-- Closes the quant-trader fencepost (`@i 1..np` loops produce np-1 results,
-- so the equity curve ends at length np; `at eq np` errored, and the
-- workaround was `npm=- np 1`). With negative indices everywhere, the same
-- pattern is `slc eq 0 -1` — no separate binding, no off-by-one risk.

last-element xs:L n>L n;slc xs -1 (len xs)
drop-last xs:L n>L n;slc xs 0 -1
drop-last xs:L n>L n;take -1 xs
keep-last-two xs:L n>L n;slc xs -2 (len xs)
all-but-last xs:L n>L n;take -1 xs
only-last-two xs:L n>L n;drop -2 xs
trim-edges s:t>t;slc s 1 -1
trim-edges s:t>t;slc s 1 (- (len s) 1)
penultimate xs:L n>L n;slc xs -3 -1

-- run: last-element [10,20,30,40,50]
-- out: [50]
Expand All @@ -33,3 +34,5 @@ trim-edges s:t>t;slc s 1 -1
-- out: [40, 50]
-- run: trim-edges "(quoted)"
-- out: quoted
-- run: penultimate [10,20,30,40,50]
-- out: [30, 40]
24 changes: 24 additions & 0 deletions examples/slc-to-end.ilo
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
-- `slc xs s -1` with non-negative `s` reads the end as "to end of
-- list/text". This is an ergonomic exception to ilo's otherwise
-- Python-style negative-index handling: if you carry a Python/JS mental
-- model where `-1` means "the end", it just works without computing
-- `(len xs)` at the call site.
--
-- The sugar is intentionally narrow:
-- - sugar fires only when `s >= 0` AND `e == -1`
-- - any other negative end (e.g. `-2`) keeps Python-style semantics
-- - negative starts also keep Python-style (so `slc xs -3 -1` is the
-- penultimate window, not "from the third-from-last to the end")
--
-- If you want the "drop last" Python shape, use `take -1 xs` instead.

drop-prefix xs:L n>L n;slc xs 2 -1
suffix-from i:n xs:L n>L n;slc xs i -1
tail-of-string s:t>t;slc s 1 -1

-- run: drop-prefix [10,20,30,40,50]
-- out: [30, 40, 50]
-- run: suffix-from 3 [10,20,30,40,50]
-- out: [40, 50]
-- run: tail-of-string "hello"
-- out: ello
2 changes: 1 addition & 1 deletion skills/ilo/ilo-builtins-core.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ See `examples/default-on-err.ilo` for the full pattern set.

## List

`len hd tl at lst take drop slc`; `rev srt rsrt unq uniqby flat grp zip enumerate range`; `chunks window flatmap partition`; `setunion setinter setdiff`. `at xs i` floors floats; negative indexes from end (same for `slc take drop`). Bounds clamp. **`lst xs i v` IS the list-set / `lset` / `setat` builtin** — returns a new list with index `i` replaced by `v` (alias `lset`; reach for `lst` whenever you'd write `xs[i] = v` in Python). Last element = `at xs -1`. `srt` and `srt fn xs` are stable: equal elements (or equal keys) keep their input order, so merging parallel records sorted by a shared timestamp keeps the per-source ordering inside each tie group.
`len hd tl at lst take drop slc`; `rev srt rsrt unq uniqby flat grp zip enumerate range`; `chunks window flatmap partition`; `setunion setinter setdiff`. `at xs i` floors floats; negative indexes from end (same for `slc take drop`). Bounds clamp. **`lst xs i v` IS the list-set / `lset` / `setat` builtin** — returns a new list with index `i` replaced by `v` (alias `lset`; reach for `lst` whenever you'd write `xs[i] = v` in Python). Last element = `at xs -1`. **`slc xs s -1` with `s>=0` means "to end of list/text"** (Python/JS sugar): `slc xs 2 -1` is everything from index 2 onward; `slc "hello" 0 -1` is `"hello"`. Other negative ends keep relative-offset semantics (`slc xs 0 -2` drops the last two). To "drop the last element" use `take -1 xs`, not `slc xs 0 -1`. `srt` and `srt fn xs` are stable: equal elements (or equal keys) keep their input order, so merging parallel records sorted by a shared timestamp keeps the per-source ordering inside each tie group.

## HOFs

Expand Down
22 changes: 22 additions & 0 deletions src/builtins.rs
Original file line number Diff line number Diff line change
Expand Up @@ -949,6 +949,28 @@ pub(crate) fn resolve_slice_bound(raw: i64, len: usize) -> usize {
adjusted.clamp(0, len_i) as usize
}

/// Resolve `slc`'s `end` bound with the `-1 = to end` sugar.
///
/// `slc` historically treated negative end indices as Python-style relative
/// offsets, so `slc s 0 -1` dropped the last element. Agents trained on
/// Python/JS keep reaching for `-1` to mean "to end of string/list" instead,
/// so we add a narrow ergonomic exception: when `start_raw >= 0` and
/// `end_raw == -1`, treat the end as `len`. All other shapes (negative
/// start, or end < -1) keep the Python-style relative-offset behaviour via
/// [`resolve_slice_bound`].
///
/// This is intentionally `-1` only, not "any negative end". Treating every
/// negative end as "to end" would silently break the existing
/// `slc xs -3 -1` / `slc "hello" -99 -1` shapes that already rely on the
/// Python semantics.
#[inline]
pub(crate) fn resolve_slc_end(start_raw: i64, end_raw: i64, len: usize) -> usize {
if start_raw >= 0 && end_raw == -1 {
return len;
}
resolve_slice_bound(end_raw, len)
}

/// Resolve `take n xs` against `len`, returning the prefix length to retain.
///
/// - `n >= 0`: take the first `min(n, len)` elements.
Expand Down
15 changes: 10 additions & 5 deletions src/interpreter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3317,9 +3317,14 @@ fn call_function(env: &mut Env, name: &str, args: Vec<Value>) -> Result<Value> {
)));
}
if builtin == Some(Builtin::Slc) && args.len() == 3 {
// Both bounds accept negative integers Python-style:
// `slc xs -1 0` is empty, `slc xs 0 -1` drops the last element,
// `slc xs -2 (len xs)` returns the last two elements.
// Bounds accept negative integers Python-style, with one ergonomic
// exception on the end bound:
// `slc xs -1 (len xs)` returns the last element
// `slc xs -2 (len xs)` returns the last two elements
// `slc xs -3 -1` returns the penultimate window (Python-style)
// `slc xs 0 -1` returns the WHOLE list (-1 = "to end" sugar
// when start is non-negative; see
// `resolve_slc_end` in builtins.rs)
let start_raw = match &args[1] {
Value::Number(n) => {
if n.fract() != 0.0 {
Expand Down Expand Up @@ -3357,14 +3362,14 @@ fn call_function(env: &mut Env, name: &str, args: Vec<Value>) -> Result<Value> {
return match &args[0] {
Value::List(items) => {
let len = items.len();
let end = crate::builtins::resolve_slice_bound(end_raw, len);
let end = crate::builtins::resolve_slc_end(start_raw, end_raw, len);
let start = crate::builtins::resolve_slice_bound(start_raw, len).min(end);
Ok(Value::List(Arc::new(items[start..end].to_vec())))
}
Value::Text(s) => {
let chars: Vec<char> = s.chars().collect();
let len = chars.len();
let end = crate::builtins::resolve_slice_bound(end_raw, len);
let end = crate::builtins::resolve_slc_end(start_raw, end_raw, len);
let start = crate::builtins::resolve_slice_bound(start_raw, len).min(end);
Ok(Value::Text(Arc::new(chars[start..end].iter().collect())))
}
Expand Down
17 changes: 11 additions & 6 deletions src/vm/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12418,7 +12418,10 @@ impl<'a> VM<'a> {
}
OP_SLC => {
// Two-instruction sequence: OP_SLC A=result B=list C=start; data word A=end_reg
// Bounds accept negative integers Python-style (`-1` = last element).
// Bounds accept negative integers Python-style (`-1` = last element),
// with one ergonomic exception: when start is non-negative and end is
// exactly `-1`, end is treated as `len` (to-end sugar). See
// `crate::builtins::resolve_slc_end` for the full rule.
let a = ((inst >> 16) & 0xFF) as usize + base;
let b = ((inst >> 8) & 0xFF) as usize + base;
let c = (inst & 0xFF) as usize + base;
Expand Down Expand Up @@ -12448,7 +12451,7 @@ impl<'a> VM<'a> {
};
let chars: Vec<char> = s.chars().collect();
let len = chars.len();
let end = crate::builtins::resolve_slice_bound(end_raw, len);
let end = crate::builtins::resolve_slc_end(start_raw, end_raw, len);
let start = crate::builtins::resolve_slice_bound(start_raw, len).min(end);
let result: String = chars[start..end].iter().collect();
reg_set!(a, NanVal::heap_string(result));
Expand All @@ -12457,7 +12460,7 @@ impl<'a> VM<'a> {
h @ (HeapObj::List(_) | HeapObj::ListView { .. }) => {
let items = slice_of(h);
let len = items.len();
let end = crate::builtins::resolve_slice_bound(end_raw, len);
let end = crate::builtins::resolve_slc_end(start_raw, end_raw, len);
let start =
crate::builtins::resolve_slice_bound(start_raw, len).min(end);
let mut sliced = Vec::with_capacity(end - start);
Expand Down Expand Up @@ -16528,7 +16531,9 @@ pub(crate) extern "C" fn jit_rsrt(a: u64, span_bits: u64) -> u64 {
#[unsafe(no_mangle)]
pub(crate) extern "C" fn jit_slc(a: u64, start: u64, end: u64, span_bits: u64) -> u64 {
// Bounds accept negative integers Python-style; kept in lockstep with the
// tree-walker and OP_SLC by delegating to `resolve_slice_bound`.
// tree-walker and OP_SLC by delegating to `resolve_slice_bound`. End uses
// `resolve_slc_end` so the `-1 = to end` sugar fires for non-negative
// starts (e.g. `slc xs 0 -1` is the full list).
let vb = NanVal(a);
let vc = NanVal(start);
let vd = NanVal(end);
Expand All @@ -16552,15 +16557,15 @@ pub(crate) extern "C" fn jit_slc(a: u64, start: u64, end: u64, span_bits: u64) -
};
let chars: Vec<char> = s.chars().collect();
let len = chars.len();
let e = crate::builtins::resolve_slice_bound(e_raw, len);
let e = crate::builtins::resolve_slc_end(s_raw, e_raw, len);
let s = crate::builtins::resolve_slice_bound(s_raw, len).min(e);
return NanVal::heap_string(chars[s..e].iter().collect()).0;
}
if vb.is_heap()
&& let HeapObj::List(items) = unsafe { vb.as_heap_ref() }
{
let len = items.len();
let e = crate::builtins::resolve_slice_bound(e_raw, len);
let e = crate::builtins::resolve_slc_end(s_raw, e_raw, len);
let s = crate::builtins::resolve_slice_bound(s_raw, len).min(e);
let mut sliced = Vec::with_capacity(e - s);
for v in &items[s..e] {
Expand Down
75 changes: 64 additions & 11 deletions tests/regression_neg_index_slice.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,22 +68,45 @@ fn slc_list_last_two() {
check_all_engines("f>L n;xs=[10,20,30,40,50];slc xs -2 5", "f", "[40, 50]");
}

// `slc xs 0 -1` drops the last element.
// `slc xs 0 -1` is the `-1 = "to end"` sugar shape (start non-negative,
// end exactly `-1`): returns the whole list. See `resolve_slc_end` for the
// rule and the negative-start tests below for the Python-style behaviour
// that's preserved when start is also negative.
#[test]
fn slc_list_neg_end_drops_last() {
fn slc_list_pos_start_neg_one_end_is_full_list() {
check_all_engines(
"f>L n;xs=[10,20,30,40,50];slc xs 0 -1",
"f",
"[10, 20, 30, 40]",
"[10, 20, 30, 40, 50]",
);
}

// `slc xs -3 -1` middle slice from negatives.
// `slc xs 2 -1` with non-negative start and end == -1: still "to end".
#[test]
fn slc_list_mid_start_neg_one_end_is_to_end() {
check_all_engines("f>L n;xs=[10,20,30,40,50];slc xs 2 -1", "f", "[30, 40, 50]");
}

// `slc xs -3 -1` keeps Python-style semantics because start is negative:
// end resolves to `len - 1 = 4`, so the result is the penultimate window.
#[test]
fn slc_list_neg_both_bounds() {
fn slc_list_neg_both_bounds_keeps_python_style() {
check_all_engines("f>L n;xs=[10,20,30,40,50];slc xs -3 -1", "f", "[30, 40]");
}

// Boundary: `slc xs -1 -1` keeps Python-style (start is negative): empty.
#[test]
fn slc_list_neg_one_neg_one_python_style() {
check_all_engines("f>L n;xs=[10,20,30,40,50];slc xs -1 -1", "f", "[]");
}

// `slc xs 0 -2` is NOT sugar (end is -2, not -1): keeps Python-style,
// drops the last two elements. Pin so the sugar stays narrow.
#[test]
fn slc_list_neg_two_end_keeps_python_style() {
check_all_engines("f>L n;xs=[10,20,30,40,50];slc xs 0 -2", "f", "[10, 20, 30]");
}

// `slc xs -len 0` is empty (start clamps to 0, end clamps to 0).
#[test]
fn slc_list_neg_len_to_zero_empty() {
Expand Down Expand Up @@ -118,31 +141,61 @@ fn slc_list_pos_end_past_len_clamps() {
check_all_engines("f>L n;xs=[10,20,30];slc xs 0 99", "f", "[10, 20, 30]");
}

// Quant-trader fencepost: previously needed `npm=- np 1;at eq npm`. Now
// `slc eq -1 np` drops the last element cleanly.
// Quant-trader fencepost: `slc eq 0 -1` no longer "drops the last element"
// (post #15: `-1` end with non-negative start is "to end" sugar — see
// `resolve_slc_end`). For the drop-last shape, use `take -1 eq` instead;
// pinning both here keeps agents on the happy path.
#[test]
fn slc_quant_trader_fencepost() {
fn slc_quant_trader_sugar_is_full_list() {
check_all_engines(
"f>L n;eq=[100,101,102,103,99];slc eq 0 -1",
"f",
"[100, 101, 102, 103, 99]",
);
}

#[test]
fn take_neg_one_is_drop_last() {
// Drop-last shape now goes through `take -1`, which keeps Python-style
// negative semantics (see `resolve_take_count`).
check_all_engines(
"f>L n;eq=[100,101,102,103,99];take -1 eq",
"f",
"[100, 101, 102, 103]",
);
}

// ── slc: negative bounds on text ──────────────────────────────────────────

// Text mirror of `slc_list_pos_start_neg_one_end_is_full_list`: with a
// non-negative start, end == -1 is "to end of string" sugar.
#[test]
fn slc_text_pos_start_neg_one_end_is_full_string() {
check_all_engines("f>t;slc \"hello\" 0 -1", "f", "hello");
}

#[test]
fn slc_text_pos_start_neg_one_end_mid() {
check_all_engines("f>t;slc \"hello\" 2 -1", "f", "llo");
}

// `slc s 0 -2` keeps Python-style (drops the last two chars). Pin so the
// sugar stays narrow on text too.
#[test]
fn slc_text_neg_end_drops_last_char() {
check_all_engines("f>t;slc \"hello\" 0 -1", "f", "hell");
fn slc_text_neg_two_end_keeps_python_style() {
check_all_engines("f>t;slc \"hello\" 0 -2", "f", "hel");
}

#[test]
fn slc_text_last_three() {
check_all_engines("f>t;slc \"hello\" -3 5", "f", "llo");
}

// Negative start with end == -1: sugar does NOT fire (start is negative),
// so Python-style applies. start clamps to 0, end resolves to 4, so we get
// `hell`.
#[test]
fn slc_text_neg_beyond_len_clamps() {
fn slc_text_neg_beyond_len_clamps_python_style() {
check_all_engines("f>t;slc \"hello\" -99 -1", "f", "hell");
}

Expand Down
Loading