Skip to content
Draft
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
11 changes: 5 additions & 6 deletions .github/scripts/type_completeness.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,19 @@
Builds a Markdown summary comparing pyright `--verifytypes` reports for a
PR's base and head commits, for posting as a PR comment.
"""

from __future__ import annotations

import argparse
import json
from pathlib import Path
from typing import (
Any,
Literal
)
from typing import Any, Literal

# Decorates the patch coverage table.
STATUS_ICON = {"known": "✅", "ambiguous": "⚠️", "unknown": "❌"}


def status_of(symbol: dict[str, Any]) -> Literal['known', 'ambiguous', 'unknown']:
def status_of(symbol: dict[str, Any]) -> Literal["known", "ambiguous", "unknown"]:
"""
Maps the --verifytypes JSON boolean flags, isTypeKnown and isTypeAmbiguous, to internal
representation in script: known, ambiguous, and unknown.
Expand Down Expand Up @@ -306,7 +304,8 @@ def build_summary(base_path: Path, head_path: Path, run_url: str | None = None)
if removed_names:
parts.append(f"{len(removed_names)} no longer exported")
sections += [
"**Patch (exported symbols added or changed by this PR):** " + "; ".join(parts),
"**Patch (exported symbols added or changed by this PR):** "
+ "; ".join(parts),
"",
render_counts_table([("Patch", patch_counts)]),
"",
Expand Down
41 changes: 3 additions & 38 deletions .github/workflows/ruff.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,52 +20,17 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0

- name: Determine changed Python/notebook files
id: changed
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
base="origin/${{ github.event.pull_request.base.ref }}"
elif [ "${{ github.ref }}" = "refs/heads/main" ]; then
# origin/main already reflects this push after checkout, so diffing
# against it here would always be empty. Use the pre-push state instead.
base="${{ github.event.before }}"
# New branch or force-push: 'before' may not exist locally (or be all-zeros).
if ! git cat-file -e "$base" 2>/dev/null; then
base="HEAD^"
git cat-file -e "$base" 2>/dev/null || base=""
fi
else
base="origin/main"
fi

# Base ref not fetched/available for some reason: fall back to checking everything.
if [ -n "$base" ] && ! git cat-file -e "$base" 2>/dev/null; then
base=""
fi

if [ -z "$base" ]; then
files=$(git ls-files '*.py' '*.ipynb' | xargs)
else
files=$(git diff --name-only --diff-filter=ACMR "$base"...HEAD -- '*.py' '*.ipynb' | xargs)
fi

echo "files=$files" >> "$GITHUB_OUTPUT"

- name: Install uv
if: steps.changed.outputs.files != ''
uses: astral-sh/setup-uv@v7
with:
version: "latest"

- name: Run ruff
if: steps.changed.outputs.files != ''
run: |
uvx ruff@0.16.1 check --force-exclude --config lint.per-file-ignores={} --output-format=github ${{ steps.changed.outputs.files }}
uvx ruff@0.16.1 check --force-exclude --output-format=github .

- name: Run ruff format
if: (success() || failure()) && steps.changed.outputs.files != ''
if: success() || failure()
run: |
uvx ruff@0.16.1 format --check --diff --force-exclude ${{ steps.changed.outputs.files }}
uvx ruff@0.16.1 format --check --diff --force-exclude .
10 changes: 2 additions & 8 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,8 @@ repos:
- id: ruff
name: ruff
entry: bash -c '
base=$(git rev-parse --verify --quiet origin/main || git rev-parse --verify --quiet main) &&
files=$(git diff --name-only --diff-filter=ACMR "$base"...HEAD -- "*.py" "*.ipynb") &&
if [ -z "$files" ]; then
echo "ruff - no changed files to check";
else
uv run ruff check --force-exclude --config lint.per-file-ignores={} $files &&
uv run ruff format --check --diff --force-exclude $files;
fi'
uv run ruff check --force-exclude . &&
uv run ruff format --check --diff --force-exclude .'
language: system
pass_filenames: false
always_run: true
Expand Down
6 changes: 4 additions & 2 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,10 +166,12 @@ from typing import TYPE_CHECKING

if TYPE_CHECKING:
from chainladder.core.typing import TriangleProtocol

_MixinBase = TriangleProtocol
else:
_MixinBase = object


class TriangleMixin(_MixinBase):
# Pyright sees TriangleProtocol as the base — self has .shape, .values, .sum, etc.
# At runtime the base is object — no Protocol stubs in the MRO.
Expand All @@ -191,8 +193,8 @@ if TYPE_CHECKING:
from chainladder import Triangle
from chainladder.core.typing import TriangleProtocol

def transform(X: TriangleProtocol) -> Triangle:
...

def transform(X: TriangleProtocol) -> Triangle: ...
```

- **Input typed as `TriangleProtocol`**: accepts any object that structurally satisfies the protocol (a real `Triangle`, a mock in tests, a future subclass) without requiring a concrete import.
Expand Down
2 changes: 1 addition & 1 deletion chainladder/adjustments/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,5 @@
"ParallelogramOLF",
"Trend",
"TrendConstant",
"DisposalRate"
"DisposalRate",
]
9 changes: 6 additions & 3 deletions chainladder/adjustments/tests/test_trend.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@
def test_trend1(clrd):
tri = clrd[["CumPaidLoss", "EarnedPremDIR"]].sum()
lhs = (
cl.CapeCod(0.05)
cl
.CapeCod(0.05)
.fit(tri["CumPaidLoss"], sample_weight=tri["EarnedPremDIR"].latest_diagonal)
.ibnr_
)
rhs = (
cl.CapeCod()
cl
.CapeCod()
.fit(
cl.Trend(0.05).fit_transform(tri["CumPaidLoss"]),
sample_weight=tri["EarnedPremDIR"].latest_diagonal,
Expand All @@ -24,7 +26,8 @@ def test_trend2(raa):
tri = raa
assert (
abs(
cl.Trend(
cl
.Trend(
trends=[0.05, 0.05],
dates=[(None, "1985"), ("1985", None)],
axis="origin",
Expand Down
26 changes: 16 additions & 10 deletions chainladder/core/io.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""
Support Triangle I/O capabilities.
"""

# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
Expand All @@ -13,7 +14,7 @@

class TriangleIO:
def to_pickle(self, path, protocol=None):
""" Serializes triangle object to pickle.
"""Serializes triangle object to pickle.

Parameters
----------
Expand Down Expand Up @@ -53,7 +54,7 @@ def to_pickle(self, path, protocol=None):
dill.dump(self, pkl)

def to_json(self):
""" Serializes triangle object to json format
"""Serializes triangle object to json format

Returns
-------
Expand Down Expand Up @@ -88,8 +89,13 @@ def to_json(self):
"is_pattern": self.is_pattern,
"columns": list(self.columns),
}
out = self.cum_to_incr().dev_to_val().to_frame(
keepdims=True, origin_as_datetime=True).fillna(0)
out = (
self
.cum_to_incr()
.dev_to_val()
.to_frame(keepdims=True, origin_as_datetime=True)
.fillna(0)
)
x = out.reset_index().to_json(orient="split", date_unit="ns")
json_dict = {"metadata": json.dumps(metadata), "data": x}
sub_tris = [k for k, v in vars(self).items() if isinstance(v, TriangleIO)]
Expand All @@ -99,17 +105,17 @@ def to_json(self):
dfs = [k for k, v in vars(self).items() if isinstance(v, pd.DataFrame)]
json_dict["dfs"] = {df: getattr(self, df).to_json() for df in dfs}
dfs = [k for k, v in vars(self).items() if isinstance(v, pd.Series)]
json_dict["dfs"].update(
{df: getattr(self, df).to_frame().to_json() for df in dfs}
)
json_dict["dfs"].update({
df: getattr(self, df).to_frame().to_json() for df in dfs
})
return json.dumps(json_dict)


class EstimatorIO:
""" Class intended to allow persistence of estimator objects """
"""Class intended to allow persistence of estimator objects"""

def to_pickle(self, path, protocol=None):
""" Serializes triangle object to pickle.
"""Serializes triangle object to pickle.

Parameters
----------
Expand Down Expand Up @@ -149,7 +155,7 @@ def to_pickle(self, path, protocol=None):
dill.dump(self, pkl)

def to_json(self):
""" Serializes triangle object to json format
"""Serializes triangle object to json format

Returns
-------
Expand Down
10 changes: 10 additions & 0 deletions chainladder/core/slice.py
Original file line number Diff line number Diff line change
Expand Up @@ -562,6 +562,16 @@ def __setitem__(
-------
None
"""
# Case full slice, e.g. tri[:] = value: mirror pandas' df[:] = value by
# broadcasting across every cell rather than treating ":" as a column label.
if isinstance(key, slice):
if key == slice(None, None, None):
self.iloc[:] = value
return

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Full-slice assignment fails on sparse

Medium Severity

tri[:] = value always delegates to iloc, which rejects the sparse backend. Callers then get an error that tells them to use .at or .iat, even though this is column-style assignment. Sparse triangles (for example prism) cannot use the new full-slice API, and the test skips that backend rather than covering it.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 08d0120. Configure here.

raise TypeError(
"Partial slicing is not supported for Triangle column assignment. "
"Use tri.iloc[...] or tri.loc[...] to set values by position or label."
)
xp: ModuleType = self.get_array_module()
# Case callable, create lazy-eval virtual columns, but do not compute.
if callable(value):
Expand Down
9 changes: 7 additions & 2 deletions chainladder/core/tests/test_correlation.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,23 @@

raa = cl.load_sample("RAA")


def test_val_corr_total_true():
assert raa.valuation_correlation(p_critical=0.5, total=True)


def test_val_corr_total_false():
assert raa.valuation_correlation(p_critical=0.5, total=False)


def test_dev_corr():
assert raa.development_correlation(p_critical=0.5)


def test_dev_corr_sparse():
assert raa.set_backend('sparse').development_correlation(p_critical=0.5)
assert raa.set_backend("sparse").development_correlation(p_critical=0.5)


def test_validate_critical():
with pytest.raises(ValueError):
raa.valuation_correlation(p_critical=1.5, total=True)
raa.valuation_correlation(p_critical=1.5, total=True)
42 changes: 42 additions & 0 deletions chainladder/core/tests/test_slicing.py
Original file line number Diff line number Diff line change
Expand Up @@ -689,6 +689,48 @@ def test_setitem_existing_column_array_value(raa: Triangle) -> None:
assert tri["values"] == raa["values"] * 3


def test_setitem_full_slice_broadcasts_value(raa: Triangle) -> None:
"""
Assigning through a bare full slice, e.g. tri[:] = 0, should broadcast the
value across every cell like pandas' df[:] = value, rather than being
misread as a column label.

Parameters
----------
raa: Triangle
The raa sample data set fixture.

Returns
-------
None
"""
tri = raa.copy()
if tri.array_backend == "sparse":
pytest.skip("Test is specific to the numpy backend.")
tri[:] = 0
assert list(tri.columns) == list(raa.columns)
assert np.nansum(tri.values) == 0


def test_setitem_partial_slice_raises(raa: Triangle) -> None:
"""
Assigning through a partial slice key is ambiguous for column assignment
and should raise rather than silently creating a bogus column.

Parameters
----------
raa: Triangle
The raa sample data set fixture.

Returns
-------
None
"""
tri = raa.copy()
with pytest.raises(TypeError):
tri[1:3] = 0


def test_sparse_column_assignment(prism):
t = prism.copy()
out = t["Paid"]
Expand Down
Loading
Loading