ndplex 0.1.0__py3-none-any.whl
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.
- ndplex/__init__.py +42 -0
- ndplex/_core.py +399 -0
- ndplex/_oeis.py +120 -0
- ndplex-0.1.0.dist-info/METADATA +196 -0
- ndplex-0.1.0.dist-info/RECORD +7 -0
- ndplex-0.1.0.dist-info/WHEEL +4 -0
- ndplex-0.1.0.dist-info/licenses/LICENSE +21 -0
ndplex/__init__.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ndplex -- the n-Dimensional m-Plex array: a generic Pascal's array (rectangle,
|
|
3
|
+
cube or higher) with user-defined edges and node-generating function.
|
|
4
|
+
|
|
5
|
+
from ndplex import ndplex, oeis
|
|
6
|
+
|
|
7
|
+
p = ndplex(9) # Pascal's rectangle, 9 x 9
|
|
8
|
+
p.diag(loc=(4, 0), move=(-1, 1)) # row 4 of Pascal's triangle
|
|
9
|
+
p.as_tri() # the whole triangle, justified left
|
|
10
|
+
oeis(p.as_tri(), trim=True, size=5) # look the rows up on oeis.org
|
|
11
|
+
|
|
12
|
+
`import ndplex` needs only numpy. `oeis` is loaded on first use and needs
|
|
13
|
+
network access when called.
|
|
14
|
+
|
|
15
|
+
Attribution (see ATTRIBUTION.md for the tiered breakdown and references):
|
|
16
|
+
- Original work (2019-2024): Ted Teske -- the ndplex array, diag, as_tri
|
|
17
|
+
and oeis, in the notebook ndplex_array.ipynb.
|
|
18
|
+
- Packaging and the Phase 2 fixes (2026): Ted Teske with Claude (Anthropic).
|
|
19
|
+
"""
|
|
20
|
+
from ._core import ndplex
|
|
21
|
+
|
|
22
|
+
__version__ = "0.1.0"
|
|
23
|
+
|
|
24
|
+
__all__ = ["ndplex", "oeis", "__version__"]
|
|
25
|
+
|
|
26
|
+
_MOVED = (
|
|
27
|
+
"is not part of the ndplex package (from 0.1.0, plotting is left to the "
|
|
28
|
+
"user). The notebook's version is in ndplex_array.ipynb, in the "
|
|
29
|
+
"repository: https://github.com/tedteske/ndplex")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def __getattr__(name):
|
|
33
|
+
if name == "oeis":
|
|
34
|
+
from ._oeis import oeis
|
|
35
|
+
return oeis
|
|
36
|
+
if name in ("plot_ndplex", "diag_as_hist"):
|
|
37
|
+
raise ImportError(f"{name} {_MOVED}")
|
|
38
|
+
raise AttributeError(f"module 'ndplex' has no attribute {name!r}")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def __dir__():
|
|
42
|
+
return sorted(set(globals()) | {"oeis"})
|
ndplex/_core.py
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The ndplex array: a generic Pascal's array of any dimension.
|
|
3
|
+
|
|
4
|
+
Original design and code: Ted Teske, 2019-2024 (the notebook
|
|
5
|
+
ndplex_array.ipynb). Package version: Ted Teske with Claude (Anthropic),
|
|
6
|
+
2026; the changes from the notebook are listed in docs/phase2_package.md.
|
|
7
|
+
"""
|
|
8
|
+
import itertools as it
|
|
9
|
+
import operator
|
|
10
|
+
import pickle
|
|
11
|
+
import warnings
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
__all__ = ["ndplex"]
|
|
16
|
+
|
|
17
|
+
# Padding and fill values below are created with np.zeros or assigned as the
|
|
18
|
+
# Python int 0. For dtype=object both give Python int zeros, so sums of
|
|
19
|
+
# object arrays stay exact (the notebook padded with np.pad, which inserts
|
|
20
|
+
# numpy int64 zeros: audit finding N12).
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _trim_outside(values, inside):
|
|
24
|
+
"""Trim trailing positions that lie wholly outside the array.
|
|
25
|
+
|
|
26
|
+
`inside` has the shape of `values` and is True where a value was read
|
|
27
|
+
from the array (False where it is zero padding). Along each axis, the
|
|
28
|
+
result ends at the last position with any value read from the array,
|
|
29
|
+
so genuine zeros are kept and an all-zero line returns zeros.
|
|
30
|
+
"""
|
|
31
|
+
slices = []
|
|
32
|
+
for axis in range(values.ndim):
|
|
33
|
+
others = tuple(a for a in range(values.ndim) if a != axis)
|
|
34
|
+
hits = np.flatnonzero(inside.any(axis=others))
|
|
35
|
+
slices.append(slice(hits[-1] + 1 if hits.size else 0))
|
|
36
|
+
return values[tuple(slices)]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ndplex(np.ndarray):
|
|
40
|
+
"""
|
|
41
|
+
n-Dimensional m-Plex (ndplex) array: a generic Pascal's rectangle, cube,
|
|
42
|
+
or higher-dimensional array, as a numpy array subclass.
|
|
43
|
+
|
|
44
|
+
By default, a square array of integers with ones on the top and left
|
|
45
|
+
edges and every interior node generated by Pascal's rule (the sum of the
|
|
46
|
+
node above and the node to the left). Any number of edges, any edge
|
|
47
|
+
values and any node-generating function may be given instead.
|
|
48
|
+
|
|
49
|
+
Parameters
|
|
50
|
+
----------
|
|
51
|
+
nrows : int >= 1
|
|
52
|
+
Length of each edge; the array has shape (nrows,) * ndim.
|
|
53
|
+
edges : int, or sequence of numbers or 1-D sequences of length nrows
|
|
54
|
+
The front edges. An int n means n edges of ones. The number of edges
|
|
55
|
+
sets ndim. Edge k lies along axis k (all other coordinates 0): in
|
|
56
|
+
2-D, edges[0] fills column 0 and edges[1] fills row 0. Where edges
|
|
57
|
+
meet, at the origin, the last edge's value is kept.
|
|
58
|
+
arg_locs : None or sequence of sequences of ints <= 0 or None
|
|
59
|
+
Locations, relative to the node being generated, of the arguments
|
|
60
|
+
passed to `func`, in that order. None (the default) means the
|
|
61
|
+
neighbour one step back along each axis: [[-1, 0], [0, -1]] in 2-D.
|
|
62
|
+
A None coordinate means "on the edge" (coordinate 0 on that axis).
|
|
63
|
+
A location outside the array reads 0. Positive offsets are not
|
|
64
|
+
allowed (those nodes have not been computed yet).
|
|
65
|
+
func : callable
|
|
66
|
+
Called with the list of argument values for each interior node;
|
|
67
|
+
returns the node's value. Default: the builtin `sum`.
|
|
68
|
+
dtype : numpy dtype
|
|
69
|
+
Default np.int64 on every platform. int64 is exact only while every
|
|
70
|
+
node fits: the default 2-D array is exact for i + j <= 66 and the
|
|
71
|
+
default 3-D array for i + j + k <= 43. If any node overflows (in
|
|
72
|
+
func's arithmetic, or because func returns an integer that does not
|
|
73
|
+
fit dtype), one RuntimeWarning names the first node that did; values
|
|
74
|
+
are kept as numpy computes them (wrapped modulo 2**64, or 2**bits
|
|
75
|
+
for a narrower integer dtype). The same holds on every platform and
|
|
76
|
+
NumPy version. Use dtype=object for
|
|
77
|
+
exact integers of any size, or reduce inside `func` (for example a
|
|
78
|
+
modulus) when only a residue is needed.
|
|
79
|
+
|
|
80
|
+
Attributes
|
|
81
|
+
----------
|
|
82
|
+
nrows : int or None
|
|
83
|
+
Length of each edge, read from the shape (None if the sides differ,
|
|
84
|
+
as for some slices).
|
|
85
|
+
edges, func, arg_locs : the parameters the array was built with,
|
|
86
|
+
carried along by slices, copies and arithmetic as a record; they are
|
|
87
|
+
not used after construction.
|
|
88
|
+
|
|
89
|
+
Methods
|
|
90
|
+
-------
|
|
91
|
+
diag(loc, move, sum_axis)
|
|
92
|
+
A line or cross-section through the array.
|
|
93
|
+
as_tri(start, end, sums)
|
|
94
|
+
The array as a triangle (2-D), tetrahedron (3-D) or n-simplex.
|
|
95
|
+
|
|
96
|
+
Notes
|
|
97
|
+
-----
|
|
98
|
+
Slices and arithmetic return ndplex arrays; reductions such as `sum`
|
|
99
|
+
and `max` return plain numpy scalars; `diag` and `as_tri` return plain
|
|
100
|
+
numpy arrays. Pickling keeps the attributes, except a `func` that cannot
|
|
101
|
+
itself be pickled (such as a lambda), which comes back as None.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
def __new__(cls, nrows, edges=(1, 1), arg_locs=None, func=sum,
|
|
105
|
+
dtype=np.int64):
|
|
106
|
+
if nrows < 1:
|
|
107
|
+
raise ValueError(f"nrows must be at least 1, got {nrows}")
|
|
108
|
+
|
|
109
|
+
if isinstance(edges, (int, np.integer)):
|
|
110
|
+
if edges < 1:
|
|
111
|
+
raise ValueError(f"edges must be at least 1, got {edges}")
|
|
112
|
+
edges = (1,) * int(edges)
|
|
113
|
+
ndim = len(edges)
|
|
114
|
+
if ndim < 1:
|
|
115
|
+
raise ValueError("edges must not be empty")
|
|
116
|
+
for k, e in enumerate(edges):
|
|
117
|
+
shape_e = np.shape(e)
|
|
118
|
+
if not (shape_e == () or shape_e == (nrows,)):
|
|
119
|
+
raise ValueError(
|
|
120
|
+
f"edge {k} must be a number or a 1-D sequence of length "
|
|
121
|
+
f"nrows={nrows}; got shape {shape_e}")
|
|
122
|
+
|
|
123
|
+
base = np.zeros((nrows,) * ndim, dtype=dtype)
|
|
124
|
+
|
|
125
|
+
# Populate the front edges: rotate the axes so that each edge in turn
|
|
126
|
+
# lies along the last axis, and fill it. After ndim rotations the
|
|
127
|
+
# axes are back in their original order.
|
|
128
|
+
view = base
|
|
129
|
+
edge_loc = (0,) * (ndim - 1) + (slice(None),)
|
|
130
|
+
for k in range(ndim):
|
|
131
|
+
view = np.moveaxis(view, 0, -1)
|
|
132
|
+
view[edge_loc] = edges[k]
|
|
133
|
+
|
|
134
|
+
if arg_locs is None or len(arg_locs) == 0:
|
|
135
|
+
arg_locs = -np.eye(ndim, dtype=int)
|
|
136
|
+
offsets = [tuple(int(v) for v in row) for row in arg_locs]
|
|
137
|
+
else:
|
|
138
|
+
offsets = []
|
|
139
|
+
for loc in arg_locs:
|
|
140
|
+
if len(loc) != ndim:
|
|
141
|
+
raise ValueError(
|
|
142
|
+
f"each arg_loc needs {ndim} coordinates, got "
|
|
143
|
+
f"{tuple(loc)}")
|
|
144
|
+
offset = []
|
|
145
|
+
for v in loc:
|
|
146
|
+
if v is not None:
|
|
147
|
+
v = operator.index(v)
|
|
148
|
+
if v > 0:
|
|
149
|
+
raise ValueError(
|
|
150
|
+
f"arg_locs offsets must be <= 0 (or None), "
|
|
151
|
+
f"got {tuple(loc)}")
|
|
152
|
+
offset.append(v)
|
|
153
|
+
offsets.append(tuple(offset))
|
|
154
|
+
|
|
155
|
+
zero = base.dtype.type(0) # for dtype=object, the Python int 0
|
|
156
|
+
# Integer dtypes: also check that each integer func returns fits the
|
|
157
|
+
# dtype before it is stored, and wrap it if not. numpy's own overflow
|
|
158
|
+
# check (errstate, below) only sees overflow inside func's
|
|
159
|
+
# arithmetic, and numpy handles a value too wide for the dtype
|
|
160
|
+
# differently by version: NumPy < 2 wraps it silently on assignment,
|
|
161
|
+
# NumPy 2 raises OverflowError. The wide value is what the default
|
|
162
|
+
# func=sum returns on NumPy < 2 for dtypes narrower than the
|
|
163
|
+
# platform's default int (int8/16/32 on Linux and macOS), because
|
|
164
|
+
# sum's start value 0 promotes the total to that default int.
|
|
165
|
+
if np.issubdtype(base.dtype, np.integer):
|
|
166
|
+
info = np.iinfo(base.dtype)
|
|
167
|
+
low, high = int(info.min), int(info.max)
|
|
168
|
+
span = high - low + 1 # 2**bits
|
|
169
|
+
stored_type = base.dtype.type # values of this type always fit
|
|
170
|
+
else:
|
|
171
|
+
low = None
|
|
172
|
+
first_overflow = None
|
|
173
|
+
with np.errstate(over="raise"):
|
|
174
|
+
for node in it.product(range(nrows), repeat=ndim):
|
|
175
|
+
if node.count(0) >= ndim - 1:
|
|
176
|
+
continue # on an edge
|
|
177
|
+
args = []
|
|
178
|
+
for offset in offsets:
|
|
179
|
+
target = tuple(0 if o is None else n + o
|
|
180
|
+
for n, o in zip(node, offset))
|
|
181
|
+
args.append(zero if min(target) < 0 else base[target])
|
|
182
|
+
try:
|
|
183
|
+
value = func(args)
|
|
184
|
+
except FloatingPointError:
|
|
185
|
+
# numpy reports the overflow; keep its wrapped value.
|
|
186
|
+
with np.errstate(over="ignore"):
|
|
187
|
+
value = func(args)
|
|
188
|
+
if first_overflow is None:
|
|
189
|
+
first_overflow = node
|
|
190
|
+
if (low is not None and type(value) is not stored_type
|
|
191
|
+
and isinstance(value, (int, np.integer))):
|
|
192
|
+
v = int(value)
|
|
193
|
+
if not low <= v <= high:
|
|
194
|
+
value = (v - low) % span + low # wrap, as numpy does
|
|
195
|
+
if first_overflow is None:
|
|
196
|
+
first_overflow = node
|
|
197
|
+
base[node] = value
|
|
198
|
+
|
|
199
|
+
if first_overflow is not None:
|
|
200
|
+
warnings.warn(
|
|
201
|
+
f"ndplex: node values overflowed {base.dtype} from node "
|
|
202
|
+
f"{first_overflow} on (in generation order); that node and "
|
|
203
|
+
f"those computed from it are wrong. Use dtype=object for exact "
|
|
204
|
+
f"integers, or reduce inside func (e.g. a modulus).",
|
|
205
|
+
RuntimeWarning, stacklevel=2)
|
|
206
|
+
|
|
207
|
+
obj = base.view(cls)
|
|
208
|
+
obj.edges, obj.func, obj.arg_locs = edges, func, arg_locs
|
|
209
|
+
return obj
|
|
210
|
+
|
|
211
|
+
# --- numpy subclass plumbing ---------------------------------------------
|
|
212
|
+
|
|
213
|
+
def __array_finalize__(self, obj):
|
|
214
|
+
self.edges = getattr(obj, "edges", None)
|
|
215
|
+
self.func = getattr(obj, "func", None)
|
|
216
|
+
self.arg_locs = getattr(obj, "arg_locs", None)
|
|
217
|
+
|
|
218
|
+
def __array_wrap__(self, obj, context=None, return_scalar=False):
|
|
219
|
+
out = super().__array_wrap__(obj, context)
|
|
220
|
+
if out.ndim == 0:
|
|
221
|
+
return out[()] # reductions give plain scalars
|
|
222
|
+
return out
|
|
223
|
+
|
|
224
|
+
def __reduce__(self):
|
|
225
|
+
constructor, args, state = super().__reduce__()
|
|
226
|
+
func = self.func
|
|
227
|
+
try:
|
|
228
|
+
pickle.dumps(func)
|
|
229
|
+
except Exception:
|
|
230
|
+
func = None
|
|
231
|
+
return constructor, args, (state, (self.edges, func, self.arg_locs))
|
|
232
|
+
|
|
233
|
+
def __setstate__(self, state):
|
|
234
|
+
array_state, (edges, func, arg_locs) = state
|
|
235
|
+
super().__setstate__(array_state)
|
|
236
|
+
self.edges, self.func, self.arg_locs = edges, func, arg_locs
|
|
237
|
+
|
|
238
|
+
@property
|
|
239
|
+
def nrows(self):
|
|
240
|
+
"""Length of each edge, from the shape; None if the sides differ."""
|
|
241
|
+
s = self.shape
|
|
242
|
+
if s and all(x == s[0] for x in s):
|
|
243
|
+
return s[0]
|
|
244
|
+
return None
|
|
245
|
+
|
|
246
|
+
# --- lines and cross-sections --------------------------------------------
|
|
247
|
+
|
|
248
|
+
def _line(self, loc, move):
|
|
249
|
+
"""Values along the moves from loc, and where they lie in the array.
|
|
250
|
+
|
|
251
|
+
Returns (values, inside): plain arrays of the same shape, one axis
|
|
252
|
+
per move (last move first) plus any axes loc does not fix. Positions
|
|
253
|
+
outside the array hold zero and are False in `inside`.
|
|
254
|
+
"""
|
|
255
|
+
a = np.asarray(self)
|
|
256
|
+
if len(loc) == 0:
|
|
257
|
+
loc = (0,) * a.ndim
|
|
258
|
+
k = len(loc)
|
|
259
|
+
if len(move) == 0:
|
|
260
|
+
move = (1,) * k
|
|
261
|
+
if np.isscalar(move[0]):
|
|
262
|
+
move = [move]
|
|
263
|
+
if k > a.ndim:
|
|
264
|
+
raise ValueError(f"loc has {k} coordinates; the array has "
|
|
265
|
+
f"{a.ndim} dimensions")
|
|
266
|
+
for m in move:
|
|
267
|
+
if len(m) != k:
|
|
268
|
+
raise ValueError(f"each move needs {k} coordinates (as many "
|
|
269
|
+
f"as loc), got {tuple(m)}")
|
|
270
|
+
|
|
271
|
+
steps = np.arange(max(a.shape))
|
|
272
|
+
coords = []
|
|
273
|
+
for dim in range(k):
|
|
274
|
+
c = loc[dim]
|
|
275
|
+
for i in range(len(move)):
|
|
276
|
+
step_grid = np.broadcast_to(steps, (len(steps),) * (i + 1)).T
|
|
277
|
+
c = step_grid * move[i][dim] + c
|
|
278
|
+
coords.append(c)
|
|
279
|
+
coords = np.stack(coords)
|
|
280
|
+
|
|
281
|
+
bounds = np.array(a.shape[:k]).reshape((k,) + (1,) * (coords.ndim - 1))
|
|
282
|
+
inside = ((coords >= 0) & (coords < bounds)).all(axis=0)
|
|
283
|
+
index = tuple(np.where(inside, coords, 0))
|
|
284
|
+
values = a[index]
|
|
285
|
+
rest = (1,) * (values.ndim - inside.ndim)
|
|
286
|
+
inside = np.broadcast_to(inside.reshape(inside.shape + rest),
|
|
287
|
+
values.shape)
|
|
288
|
+
values[~inside] = 0
|
|
289
|
+
return values, inside
|
|
290
|
+
|
|
291
|
+
def diag(self, loc=(), move=(), sum_axis=None):
|
|
292
|
+
"""
|
|
293
|
+
A diagonal: a line or cross-section through the array.
|
|
294
|
+
|
|
295
|
+
Starts at the node (or, with fewer coordinates than dimensions, the
|
|
296
|
+
sub-array) `loc` and steps through the array by `move`, returning
|
|
297
|
+
the values it passes. With several moves, the result has one axis
|
|
298
|
+
per move, the last move first.
|
|
299
|
+
|
|
300
|
+
Parameters
|
|
301
|
+
----------
|
|
302
|
+
loc : tuple of ints
|
|
303
|
+
Starting coordinates. Default: the origin.
|
|
304
|
+
move : tuple of ints, or list of such tuples
|
|
305
|
+
Step(s), each with as many coordinates as loc. Default: one step
|
|
306
|
+
along every axis that loc fixes (with the default loc, the main
|
|
307
|
+
diagonal).
|
|
308
|
+
sum_axis : None or int
|
|
309
|
+
If given, sum the result over this axis.
|
|
310
|
+
|
|
311
|
+
Returns
|
|
312
|
+
-------
|
|
313
|
+
numpy.ndarray (0-d for a single node), or its sums over sum_axis.
|
|
314
|
+
|
|
315
|
+
Notes
|
|
316
|
+
-----
|
|
317
|
+
The result ends where the moves leave the array, so its length is
|
|
318
|
+
set by the geometry, not the values: genuine trailing zeros are
|
|
319
|
+
kept, and a line of zeros returns zeros. Positions outside the array
|
|
320
|
+
but before the end read as 0.
|
|
321
|
+
"""
|
|
322
|
+
values, inside = self._line(loc, move)
|
|
323
|
+
values = np.squeeze(values)
|
|
324
|
+
inside = np.squeeze(inside)
|
|
325
|
+
result = _trim_outside(values, inside)
|
|
326
|
+
if sum_axis is None:
|
|
327
|
+
return result
|
|
328
|
+
return result.sum(axis=sum_axis)
|
|
329
|
+
|
|
330
|
+
def as_tri(self, start=0, end=None, sums=False):
|
|
331
|
+
"""
|
|
332
|
+
The array as a triangle (2-D), tetrahedron (3-D), 4-simplex (4-D),
|
|
333
|
+
and so on: the series of (n-1)-dimensional cross-sections through
|
|
334
|
+
the array along its anti-diagonals.
|
|
335
|
+
|
|
336
|
+
In the simplest case, Pascal's rectangle as Pascal's triangle,
|
|
337
|
+
justified left. Every layer is justified left (toward index 0 on
|
|
338
|
+
each axis): element [k][x_1, ..., x_(n-1)] is the node whose
|
|
339
|
+
coordinates are x_1, ..., x_(n-1) on the axes other than axis n-2,
|
|
340
|
+
and k - (x_1 + ... + x_(n-1)) on axis n-2. In 4-D, tri[k][a, b, c]
|
|
341
|
+
is the node (a, b, k - a - b - c, c). Returns the result (it does
|
|
342
|
+
not print it).
|
|
343
|
+
|
|
344
|
+
Parameters
|
|
345
|
+
----------
|
|
346
|
+
start, end : int
|
|
347
|
+
First and last row (2-D) or layer (3-D and up). Default: all.
|
|
348
|
+
sums : bool
|
|
349
|
+
If True, return the sum of each row or layer instead.
|
|
350
|
+
|
|
351
|
+
Returns
|
|
352
|
+
-------
|
|
353
|
+
numpy.ndarray
|
|
354
|
+
If sums is False, shape (end - start + 1,) + (end + 1,) * (ndim - 1),
|
|
355
|
+
with each cross-section padded with zeros on the right. If sums is
|
|
356
|
+
True, the 1-D array of sums. A genuine zero in a cross-section is
|
|
357
|
+
indistinguishable from the padding.
|
|
358
|
+
"""
|
|
359
|
+
a = np.asarray(self)
|
|
360
|
+
if a.ndim < 2:
|
|
361
|
+
raise ValueError("as_tri needs an array of at least 2 dimensions")
|
|
362
|
+
if any(x != a.shape[0] for x in a.shape):
|
|
363
|
+
raise ValueError(f"as_tri needs equal sides; the shape is {a.shape}")
|
|
364
|
+
ndim = a.ndim
|
|
365
|
+
if end is None:
|
|
366
|
+
end = a.shape[0] - 1
|
|
367
|
+
|
|
368
|
+
# The cross-section of layer i starts at the node with i on the
|
|
369
|
+
# pivot axis (ndim - 2) and 0 elsewhere. Each move takes one step
|
|
370
|
+
# from the pivot axis to another axis: first the last axis, then
|
|
371
|
+
# the axes before the pivot, nearest first.
|
|
372
|
+
# 2-D: [(-1, 1)]
|
|
373
|
+
# 3-D: [(0, -1, 1), (1, -1, 0)]
|
|
374
|
+
# 4-D: [(0, 0, -1, 1), (0, 1, -1, 0), (1, 0, -1, 0)]
|
|
375
|
+
# So every layer is a left-justified simplex: in 4-D, element
|
|
376
|
+
# [a, b, c] of layer i is the node (a, b, i - a - b - c, c).
|
|
377
|
+
# (The notebook's pattern took later moves' steps from the
|
|
378
|
+
# neighbouring axis instead, which sheared layers in 4-D and up;
|
|
379
|
+
# 2-D and 3-D are unchanged.)
|
|
380
|
+
pivot = ndim - 2
|
|
381
|
+
unit = np.eye(ndim, dtype=int)
|
|
382
|
+
targets = [ndim - 1] + list(range(pivot - 1, -1, -1))
|
|
383
|
+
moves = np.array([unit[t] - unit[pivot] for t in targets])
|
|
384
|
+
loc = [0] * ndim # the cross-section starts at loc[pivot] = i
|
|
385
|
+
|
|
386
|
+
width = (end + 1,) * (ndim - 1)
|
|
387
|
+
rows = []
|
|
388
|
+
for i in range(start, end + 1):
|
|
389
|
+
loc[pivot] = i
|
|
390
|
+
values, inside = self._line(tuple(loc), moves)
|
|
391
|
+
section = _trim_outside(values, inside)
|
|
392
|
+
row = np.zeros(width, dtype=a.dtype)
|
|
393
|
+
row[tuple(slice(s) for s in section.shape)] = section
|
|
394
|
+
rows.append(row)
|
|
395
|
+
|
|
396
|
+
tri = np.stack(rows, axis=0)
|
|
397
|
+
if sums:
|
|
398
|
+
return tri.sum(axis=tuple(range(1, ndim)))
|
|
399
|
+
return tri
|
ndplex/_oeis.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""
|
|
2
|
+
oeis(): look up each row of an array in the On-Line Encyclopedia of Integer
|
|
3
|
+
Sequences (https://oeis.org).
|
|
4
|
+
|
|
5
|
+
Original function: Ted Teske, 2019-2024 (the notebook ndplex_array.ipynb).
|
|
6
|
+
Package version (https, a timeout, standard library only, failures reported
|
|
7
|
+
as failures, a User-Agent naming ndplex): Ted Teske with Claude (Anthropic),
|
|
8
|
+
2026.
|
|
9
|
+
"""
|
|
10
|
+
import urllib.parse
|
|
11
|
+
import urllib.request
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
__all__ = ["oeis"]
|
|
16
|
+
|
|
17
|
+
SEARCH_URL = "https://oeis.org/search"
|
|
18
|
+
USER_AGENT = "ndplex/{version} (+https://github.com/tedteske/ndplex)"
|
|
19
|
+
_GREETING = "# Greetings from The On-Line Encyclopedia"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def urlopen(url, timeout):
|
|
23
|
+
"""The network call, kept separate so tests can replace it.
|
|
24
|
+
|
|
25
|
+
The request names ndplex in its User-Agent header. oeis.org refuses
|
|
26
|
+
Python's default one ("Python-urllib/3.x") with HTTP 403 (Cloudflare
|
|
27
|
+
error 1010), checked from Google Colab on 7 Nov 2026.
|
|
28
|
+
"""
|
|
29
|
+
from . import __version__ # here, not at the top: ndplex imports this module
|
|
30
|
+
request = urllib.request.Request(
|
|
31
|
+
url, headers={"User-Agent": USER_AGENT.format(version=__version__)})
|
|
32
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
33
|
+
return response.read().decode("utf-8", errors="replace")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _lookup(sequence, timeout):
|
|
37
|
+
"""Best OEIS match for `sequence` as (A-number, name), or None.
|
|
38
|
+
|
|
39
|
+
Raises OSError if OEIS cannot be reached and ValueError if the answer
|
|
40
|
+
is not in OEIS's text format.
|
|
41
|
+
"""
|
|
42
|
+
query = urllib.parse.urlencode(
|
|
43
|
+
{"q": ",".join(str(s) for s in sequence), "n": "1", "fmt": "text"})
|
|
44
|
+
text = urlopen(f"{SEARCH_URL}?{query}", timeout)
|
|
45
|
+
for line in text.splitlines():
|
|
46
|
+
if line.startswith("%N "):
|
|
47
|
+
anum, _, name = line[3:].partition(" ")
|
|
48
|
+
return anum, name
|
|
49
|
+
if text.lstrip().startswith(_GREETING):
|
|
50
|
+
return None # a well-formed answer with no sequence in it
|
|
51
|
+
raise ValueError("unrecognized response from oeis.org")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def oeis(array, size=None, trim=False, quiet=False, timeout=10):
|
|
55
|
+
"""
|
|
56
|
+
Search OEIS for the sequence in each row of an array.
|
|
57
|
+
|
|
58
|
+
Parameters
|
|
59
|
+
----------
|
|
60
|
+
array : array_like, 1-D or 2-D
|
|
61
|
+
A 1-D array is searched as one sequence; a 2-D array row by row.
|
|
62
|
+
size : int
|
|
63
|
+
Number of rows to search (2-D only). Default: all rows.
|
|
64
|
+
trim : bool
|
|
65
|
+
If True, trim zeros from the start and end of each row first (useful
|
|
66
|
+
for the rows of `as_tri`).
|
|
67
|
+
quiet : bool
|
|
68
|
+
If False (the default), print one line per row and return None, as
|
|
69
|
+
the notebook did. If True, print nothing and return the results.
|
|
70
|
+
timeout : float
|
|
71
|
+
Seconds to wait for each answer from oeis.org.
|
|
72
|
+
|
|
73
|
+
Returns
|
|
74
|
+
-------
|
|
75
|
+
None, or (with quiet=True) a list with one entry per row searched (one
|
|
76
|
+
entry for 1-D input): (A-number, name) for the best match, or None if
|
|
77
|
+
OEIS has no match.
|
|
78
|
+
|
|
79
|
+
Raises
|
|
80
|
+
------
|
|
81
|
+
With quiet=True only: OSError (including urllib.error.URLError and
|
|
82
|
+
TimeoutError) if oeis.org cannot be reached, ValueError if its answer is
|
|
83
|
+
not in the expected format. When printing, these are reported on the
|
|
84
|
+
row's line as "lookup failed (...)" instead.
|
|
85
|
+
|
|
86
|
+
Notes
|
|
87
|
+
-----
|
|
88
|
+
Each row is a separate request to oeis.org, which needs a network
|
|
89
|
+
connection. Please search a few rows at a time.
|
|
90
|
+
"""
|
|
91
|
+
array = np.asarray(array)
|
|
92
|
+
if array.ndim == 1:
|
|
93
|
+
rows, labels = [array], [None]
|
|
94
|
+
elif array.ndim == 2:
|
|
95
|
+
n = len(array) if size is None else size
|
|
96
|
+
rows, labels = list(array[:n]), list(range(n))
|
|
97
|
+
else:
|
|
98
|
+
raise ValueError(f"oeis takes a 1-D or 2-D array, not {array.ndim}-D")
|
|
99
|
+
|
|
100
|
+
if not quiet:
|
|
101
|
+
print("OEIS Search Results")
|
|
102
|
+
print("===================")
|
|
103
|
+
|
|
104
|
+
results = []
|
|
105
|
+
for row, label in zip(rows, labels):
|
|
106
|
+
sequence = np.trim_zeros(row) if trim else row
|
|
107
|
+
if quiet:
|
|
108
|
+
results.append(_lookup(sequence, timeout))
|
|
109
|
+
continue
|
|
110
|
+
prefix = "" if label is None else f"row {label}: "
|
|
111
|
+
try:
|
|
112
|
+
match = _lookup(sequence, timeout)
|
|
113
|
+
except (OSError, ValueError) as err:
|
|
114
|
+
print(f"{prefix}lookup failed ({err})")
|
|
115
|
+
continue
|
|
116
|
+
if match is None:
|
|
117
|
+
print(f"{prefix}no match")
|
|
118
|
+
else:
|
|
119
|
+
print(f"{prefix}https://oeis.org/{match[0]} {match[1]}")
|
|
120
|
+
return results if quiet else None
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ndplex
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: The n-Dimensional m-Plex array: a generic Pascal's array (rectangle, cube or higher) with user-defined edges and node-generating function.
|
|
5
|
+
Project-URL: Homepage, https://github.com/tedteske/ndplex
|
|
6
|
+
Project-URL: Source, https://github.com/tedteske/ndplex
|
|
7
|
+
Project-URL: Issues, https://github.com/tedteske/ndplex/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/tedteske/ndplex/blob/master/CHANGELOG.md
|
|
9
|
+
Author: Ted Teske
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: binomial,combinatorics,integer sequences,multinomial,numpy,oeis,pascal's array,pascal's triangle,simplex
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Education
|
|
15
|
+
Classifier: Intended Audience :: Science/Research
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
25
|
+
Classifier: Topic :: Scientific/Engineering :: Mathematics
|
|
26
|
+
Requires-Python: >=3.10
|
|
27
|
+
Requires-Dist: numpy>=1.23.5
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# ndplex
|
|
31
|
+
|
|
32
|
+
**The n-Dimensional m-Plex array: a generic Pascal's array for NumPy.**
|
|
33
|
+
|
|
34
|
+
`ndplex` builds Pascal's rectangle, cube or hypercube as a NumPy array, and lets you change everything that defines it: the values on its edges, the rule that generates each interior node, and which neighbours that rule reads. It can then cut lines and cross-sections through the array, show the array as a triangle, tetrahedron or n-simplex, and look its rows up in the [On-Line Encyclopedia of Integer Sequences](https://oeis.org).
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
pip install ndplex
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
In Google Colab, run `!pip install ndplex` in a cell. ndplex needs Python 3.10 or later and NumPy 1.23.5 or later, nothing else. `oeis` needs a network connection when it is called.
|
|
43
|
+
|
|
44
|
+
## Quick start
|
|
45
|
+
|
|
46
|
+
By default, `ndplex(n)` is Pascal's rectangle: ones on the top and left edges, and every other node the sum of the node above it and the node to its left.
|
|
47
|
+
|
|
48
|
+
```pycon
|
|
49
|
+
>>> from ndplex import ndplex
|
|
50
|
+
>>> p = ndplex(6)
|
|
51
|
+
>>> print(p)
|
|
52
|
+
[[ 1 1 1 1 1 1]
|
|
53
|
+
[ 1 2 3 4 5 6]
|
|
54
|
+
[ 1 3 6 10 15 21]
|
|
55
|
+
[ 1 4 10 20 35 56]
|
|
56
|
+
[ 1 5 15 35 70 126]
|
|
57
|
+
[ 1 6 21 56 126 252]]
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`as_tri` shows the same array as Pascal's triangle, justified left, or returns the sum of each row.
|
|
61
|
+
|
|
62
|
+
```pycon
|
|
63
|
+
>>> print(p.as_tri())
|
|
64
|
+
[[ 1 0 0 0 0 0]
|
|
65
|
+
[ 1 1 0 0 0 0]
|
|
66
|
+
[ 1 2 1 0 0 0]
|
|
67
|
+
[ 1 3 3 1 0 0]
|
|
68
|
+
[ 1 4 6 4 1 0]
|
|
69
|
+
[ 1 5 10 10 5 1]]
|
|
70
|
+
>>> print(p.as_tri(sums=True))
|
|
71
|
+
[ 1 2 4 8 16 32]
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
`diag` walks through the array from a starting node by a fixed step. Row 4 of the triangle is an anti-diagonal of the rectangle; the main diagonal holds the central binomial coefficients.
|
|
75
|
+
|
|
76
|
+
```pycon
|
|
77
|
+
>>> print(p.diag(loc=(4, 0), move=(-1, 1)))
|
|
78
|
+
[1 4 6 4 1]
|
|
79
|
+
>>> print(p.diag())
|
|
80
|
+
[ 1 2 6 20 70 252]
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The steeper diagonals sum to the Fibonacci numbers:
|
|
84
|
+
|
|
85
|
+
```pycon
|
|
86
|
+
>>> f = ndplex(10)
|
|
87
|
+
>>> print([int(f.diag(loc=(k, 0), move=(-2, 1)).sum()) for k in range(10)])
|
|
88
|
+
[1, 1, 2, 3, 5, 8, 13, 21, 34, 55]
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Other arrays
|
|
92
|
+
|
|
93
|
+
`ndplex(nrows, edges=(1, 1), arg_locs=None, func=sum, dtype=np.int64)`
|
|
94
|
+
|
|
95
|
+
- **`edges`** sets the front edges, as numbers or as sequences of length `nrows`. Their number sets the dimension. In 2-D, `edges[0]` fills column 0 and `edges[1]` fills row 0.
|
|
96
|
+
- **`func`** generates each interior node from a list of argument values. The default is `sum`.
|
|
97
|
+
- **`arg_locs`** gives where those arguments are, relative to the node. The default in 2-D is `[[-1, 0], [0, -1]]`, the nodes above and to the left. A `None` coordinate means "on the edge".
|
|
98
|
+
|
|
99
|
+
Odd numbers along the top edge give the squares, the square pyramidal numbers, and so on down the rows:
|
|
100
|
+
|
|
101
|
+
```pycon
|
|
102
|
+
>>> print(ndplex(5, edges=(1, [1, 3, 5, 7, 9])))
|
|
103
|
+
[[ 1 3 5 7 9]
|
|
104
|
+
[ 1 4 9 16 25]
|
|
105
|
+
[ 1 5 14 30 55]
|
|
106
|
+
[ 1 6 20 50 105]
|
|
107
|
+
[ 1 7 27 77 182]]
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Pascal's rule modulo 2 gives the Sierpinski pattern:
|
|
111
|
+
|
|
112
|
+
```pycon
|
|
113
|
+
>>> print(ndplex(6, func=lambda x: sum(x) % 2))
|
|
114
|
+
[[1 1 1 1 1 1]
|
|
115
|
+
[1 0 1 0 1 0]
|
|
116
|
+
[1 1 0 0 1 1]
|
|
117
|
+
[1 0 0 0 1 0]
|
|
118
|
+
[1 1 1 1 0 0]
|
|
119
|
+
[1 0 1 0 0 0]]
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Adding the diagonal neighbour as a third argument gives the Delannoy numbers:
|
|
123
|
+
|
|
124
|
+
```pycon
|
|
125
|
+
>>> print(ndplex(6, arg_locs=[[-1, 0], [0, -1], [-1, -1]]))
|
|
126
|
+
[[ 1 1 1 1 1 1]
|
|
127
|
+
[ 1 3 5 7 9 11]
|
|
128
|
+
[ 1 5 13 25 41 61]
|
|
129
|
+
[ 1 7 25 63 129 231]
|
|
130
|
+
[ 1 9 41 129 321 681]
|
|
131
|
+
[ 1 11 61 231 681 1683]]
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## Higher dimensions
|
|
135
|
+
|
|
136
|
+
Three edges make Pascal's cube; `as_tri` shows it as a tetrahedron, layer by layer. Each layer holds the trinomial coefficients, and the layers sum to powers of 3.
|
|
137
|
+
|
|
138
|
+
```pycon
|
|
139
|
+
>>> c = ndplex(4, edges=3)
|
|
140
|
+
>>> print(c.as_tri()[2])
|
|
141
|
+
[[1 2 1 0]
|
|
142
|
+
[2 2 0 0]
|
|
143
|
+
[1 0 0 0]
|
|
144
|
+
[0 0 0 0]]
|
|
145
|
+
>>> print(c.as_tri(sums=True))
|
|
146
|
+
[ 1 3 9 27]
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
The same works in any number of dimensions: `ndplex(5, edges=4)` is a 4-D array, and its `as_tri` is a 4-simplex.
|
|
150
|
+
|
|
151
|
+
## Large values
|
|
152
|
+
|
|
153
|
+
Node values grow quickly. With the default `int64`, the 2-D array is exact for nodes with `i + j <= 66`. If any node overflows, construction issues one `RuntimeWarning` naming the first node that did. For exact integers of any size, use `dtype=object`:
|
|
154
|
+
|
|
155
|
+
```pycon
|
|
156
|
+
>>> print(ndplex(70, dtype=object)[69, 69])
|
|
157
|
+
23623985175715118288974865541854103729000
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
When only a residue is needed, reduce inside `func` instead, as in the Sierpinski example above.
|
|
161
|
+
|
|
162
|
+
## Looking rows up in OEIS
|
|
163
|
+
|
|
164
|
+
`oeis` searches oeis.org for each row of an array and prints the best match. It sends one request per row, so please search a few rows at a time.
|
|
165
|
+
|
|
166
|
+
```python
|
|
167
|
+
from ndplex import ndplex, oeis
|
|
168
|
+
|
|
169
|
+
oeis(ndplex(5))
|
|
170
|
+
oeis(ndplex(9).as_tri(), trim=True, size=5) # rows of the triangle, zeros trimmed
|
|
171
|
+
matches = oeis(ndplex(5), quiet=True) # a list of (A-number, name) or None
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
The output has one line per row, for example `row 2: https://oeis.org/A000217 Triangular numbers: ...`. The names come from OEIS and can change. If oeis.org cannot be reached or refuses the request, the row's line says `lookup failed (...)`. Each request identifies itself to oeis.org as `ndplex/<version>`.
|
|
175
|
+
|
|
176
|
+
## Plotting
|
|
177
|
+
|
|
178
|
+
From 0.1.0, plotting is left to the user, so `import ndplex` needs only NumPy. The notebook's `plot_ndplex` and `diag_as_hist` are in [`ndplex_array.ipynb`](https://github.com/tedteske/ndplex/blob/master/ndplex_array.ipynb) in the repository; an examples notebook that installs from PyPI is planned.
|
|
179
|
+
|
|
180
|
+
## Changes from the notebook
|
|
181
|
+
|
|
182
|
+
ndplex began as the first code cell of `ndplex_array.ipynb` (2019–2024). The package keeps the notebook's results except where a change is documented. The changes are listed in the [changelog](https://github.com/tedteske/ndplex/blob/master/CHANGELOG.md), with migration notes in [`docs/`](https://github.com/tedteske/ndplex/tree/master/docs). In brief: no import side effects, plotting left out, overflow reported, `arg_locs` outside the array read 0, `diag` ends where its moves leave the array, and `as_tri` layers are justified left in every dimension.
|
|
183
|
+
|
|
184
|
+
## Background and credits
|
|
185
|
+
|
|
186
|
+
ndplex is the original work of Ted Teske (2019–2024). A general source for it is Thomas M. Green, *The Simplex, Duplex and Pascal's Triangles: Relatives of Pascal's Triangle, with Excursions Into Hyperspace* (CreateSpace, 2015; ISBN 9781514677094).
|
|
187
|
+
|
|
188
|
+
The packaging, tests and audit (2026) were developed with assistance from Claude (Anthropic). [`ATTRIBUTION.md`](https://github.com/tedteske/ndplex/blob/master/ATTRIBUTION.md) sets out which work is whose, and the references.
|
|
189
|
+
|
|
190
|
+
## Citing
|
|
191
|
+
|
|
192
|
+
Citation metadata are in [`CITATION.cff`](https://github.com/tedteske/ndplex/blob/master/CITATION.cff); GitHub's "Cite this repository" button reads it.
|
|
193
|
+
|
|
194
|
+
## License
|
|
195
|
+
|
|
196
|
+
MIT. Copyright (c) 2019 Ted Teske.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
ndplex/__init__.py,sha256=fJBOCDLp99Qlv6646z8ZIqe6mO39k1rIeTazXdnHy3Y,1480
|
|
2
|
+
ndplex/_core.py,sha256=1BFjIPM-KgUJqUe7aflM6Vstbo3yBdPra0BCGsL7Kv0,17046
|
|
3
|
+
ndplex/_oeis.py,sha256=kOV2k2GQ5RkpnXkw57of7qpG2wlxe1g4f9P7-74IXM8,4338
|
|
4
|
+
ndplex-0.1.0.dist-info/METADATA,sha256=Fw5mKK57R0oRyZERuZFhtkLSPQg3RcsKT5ix378umZs,8010
|
|
5
|
+
ndplex-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
6
|
+
ndplex-0.1.0.dist-info/licenses/LICENSE,sha256=SKsS0t2kxvXf8LvmoW2kQ0v5dMW19CQUe_l1vgx01r0,1066
|
|
7
|
+
ndplex-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2019 Ted Teske
|
|
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.
|